Line data Source code
1 : /*! 2 : * \file esys/repo/sharing/materializer_sharing.cpp 3 : * \brief Detached linked-worktree materialization (ADR-0041) 4 : * 5 : * \cond 6 : * __legal_b__ 7 : * 8 : * Copyright (c) 2026 Michel Gillet 9 : * Distributed under the MIT License. 10 : * (See accompanying file LICENSE.txt or 11 : * copy at https://opensource.org/licenses/MIT) 12 : * 13 : * __legal_e__ 14 : * \endcond 15 : * 16 : */ 17 : 18 : #include "esys/repo/esysrepo_prec.h" 19 : #include "esys/repo/sharing/materializer.h" 20 : #include "esys/repo/gitbase.h" 21 : #include "esys/repo/git/commithash.h" 22 : #include "esys/repo/git/worktree.h" 23 : 24 : #include <boost/filesystem.hpp> 25 : 26 : #include <algorithm> 27 : #include <cctype> 28 : #include <fstream> 29 : 30 : #ifdef _WIN32 31 : #ifndef WIN32_LEAN_AND_MEAN 32 : #define WIN32_LEAN_AND_MEAN 33 : #endif 34 : #include <windows.h> 35 : #endif 36 : 37 : namespace esys::repo::sharing 38 : { 39 : namespace 40 : { 41 : 42 54 : boost::filesystem::path abs_norm(const std::string &path) 43 : { 44 54 : boost::filesystem::path p(path); 45 54 : if (!p.is_absolute()) p = boost::filesystem::absolute(p); 46 54 : return p.lexically_normal(); 47 54 : } 48 : 49 37 : bool path_under(const boost::filesystem::path &child, const boost::filesystem::path &root) 50 : { 51 37 : auto c = child.lexically_normal().generic_string(); 52 37 : auto r = root.lexically_normal().generic_string(); 53 37 : if (!r.empty() && r.back() == '/') r.pop_back(); 54 37 : if (c == r) return true; 55 37 : if (r.empty()) return false; 56 37 : if (c.size() <= r.size()) return false; 57 18 : if (c.compare(0, r.size(), r) != 0) return false; 58 8 : return c[r.size()] == '/'; 59 37 : } 60 : 61 4 : bool starts_with_ci(const std::string &s, const std::string &prefix) 62 : { 63 4 : if (s.size() < prefix.size()) return false; 64 32 : for (std::size_t i = 0; i < prefix.size(); ++i) 65 : { 66 28 : if (std::tolower(static_cast<unsigned char>(s[i])) != std::tolower(static_cast<unsigned char>(prefix[i]))) 67 : return false; 68 : } 69 : return true; 70 : } 71 : 72 5 : bool is_reparse_point(const boost::filesystem::path &path) 73 : { 74 : #ifdef _WIN32 75 : const DWORD attrs = GetFileAttributesW(path.wstring().c_str()); 76 : if (attrs == INVALID_FILE_ATTRIBUTES) return false; 77 : return (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0; 78 : #else 79 5 : boost::system::error_code ec; 80 5 : return boost::filesystem::is_symlink(boost::filesystem::symlink_status(path, ec)); 81 : #endif 82 : } 83 : 84 4 : std::string read_gitfile(const boost::filesystem::path &git_file) 85 : { 86 4 : std::ifstream ifs(git_file.string()); 87 4 : if (!ifs) return ""; 88 4 : std::string line; 89 4 : std::getline(ifs, line); 90 8 : return line; 91 4 : } 92 : 93 6 : bool commit_prefix_equal(const std::string &a, const std::string &b) 94 : { 95 6 : if (a.empty() || b.empty()) return false; 96 6 : const std::size_t n = (std::min)(a.size(), b.size()); 97 : // Accept abbreviated vs full if one is prefix of the other (min 7) 98 6 : if (n < 7) return a == b; 99 6 : const std::string &shorter = a.size() <= b.size() ? a : b; 100 6 : const std::string &longer = a.size() <= b.size() ? b : a; 101 6 : return longer.compare(0, shorter.size(), shorter) == 0; 102 : } 103 : 104 : } // namespace 105 : 106 7 : DestKind classify_destination(const std::string &abs_path) 107 : { 108 7 : boost::filesystem::path path = abs_norm(abs_path); 109 7 : boost::system::error_code ec; 110 7 : if (!boost::filesystem::exists(path, ec)) return DestKind::Missing; 111 : 112 5 : if (is_reparse_point(path)) return DestKind::ReparsePoint; 113 : 114 5 : if (boost::filesystem::is_directory(path, ec)) 115 : { 116 10 : boost::filesystem::path git_path = path / ".git"; 117 5 : if (boost::filesystem::is_regular_file(git_path, ec)) 118 : { 119 4 : const std::string text = read_gitfile(git_path); 120 4 : std::string norm = text; 121 617 : for (char &c : norm) 122 613 : if (c == '\\') c = '/'; 123 4 : if (norm.find("/modules/") != std::string::npos || 124 8 : (starts_with_ci(norm, "gitdir:") && norm.find("modules") != std::string::npos)) 125 : return DestKind::InitializedSubmodule; 126 4 : if (norm.find("/worktrees/") != std::string::npos || 127 4 : (starts_with_ci(norm, "gitdir:") && norm.find("worktrees") != std::string::npos)) 128 : return DestKind::LinkedWorktree; 129 0 : return DestKind::StandaloneRepo; 130 4 : } 131 1 : if (boost::filesystem::is_directory(git_path, ec)) return DestKind::StandaloneRepo; 132 : 133 2 : bool empty = boost::filesystem::directory_iterator(path, ec) == boost::filesystem::directory_iterator(); 134 6 : if (ec) return DestKind::NonEmptyUnknown; 135 2 : return empty ? DestKind::EmptyDir : DestKind::NonEmptyUnknown; 136 5 : } 137 : 138 : return DestKind::NonEmptyUnknown; 139 7 : } 140 : 141 2 : Materializer::Materializer() = default; 142 : 143 4 : Materializer::~Materializer() = default; 144 : 145 2 : void Materializer::set_git(std::shared_ptr<GitBase> git) 146 : { 147 2 : m_git = std::move(git); 148 2 : } 149 : 150 0 : std::shared_ptr<GitBase> Materializer::get_git() const 151 : { 152 0 : return m_git; 153 : } 154 : 155 3 : const std::string &Materializer::get_last_mismatch_warning() const 156 : { 157 3 : return m_last_mismatch_warning; 158 : } 159 : 160 2 : void Materializer::set_consumer_root(const std::string &path) 161 : { 162 4 : m_consumer_root = abs_norm(path).string(); 163 2 : } 164 : 165 0 : const std::string &Materializer::get_consumer_root() const 166 : { 167 0 : return m_consumer_root; 168 : } 169 : 170 2 : void Materializer::set_provider_path(const std::string &path) 171 : { 172 4 : m_provider_path = abs_norm(path).string(); 173 2 : } 174 : 175 0 : const std::string &Materializer::get_provider_path() const 176 : { 177 0 : return m_provider_path; 178 : } 179 : 180 2 : void Materializer::set_forbidden_roots(const std::vector<std::string> &paths) 181 : { 182 2 : m_forbidden_roots.clear(); 183 5 : for (const auto &p : paths) m_forbidden_roots.push_back(abs_norm(p).string()); 184 2 : } 185 : 186 0 : const std::vector<std::string> &Materializer::get_forbidden_roots() const 187 : { 188 0 : return m_forbidden_roots; 189 : } 190 : 191 8 : Result Materializer::check_path_safety(const std::string &dest_abs) const 192 : { 193 8 : if (dest_abs.empty()) return ESYSREPO_RESULT(ResultCode::EMPTY_PATH); 194 8 : auto dest = abs_norm(dest_abs); 195 : 196 8 : if (!m_consumer_root.empty()) 197 : { 198 8 : if (!path_under(dest, abs_norm(m_consumer_root))) 199 1 : return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination escapes consumer root: " + dest.string()); 200 : } 201 : 202 7 : if (!m_provider_path.empty()) 203 : { 204 7 : auto prov = abs_norm(m_provider_path); 205 14 : if (path_under(dest, prov) || path_under(prov, dest) || dest == prov) 206 0 : return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination overlaps provider path"); 207 7 : } 208 : 209 14 : for (const auto &forbidden : m_forbidden_roots) 210 : { 211 8 : auto f = abs_norm(forbidden); 212 15 : if (path_under(dest, f) || path_under(f, dest) || dest == f) 213 1 : return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination overlaps forbidden path: " + f.string()); 214 8 : } 215 : 216 6 : return ESYSREPO_RESULT(ResultCode::OK); 217 8 : } 218 : 219 3 : Result Materializer::ensure_parent_dir(const std::string &dest_abs) const 220 : { 221 3 : boost::filesystem::path parent = abs_norm(dest_abs).parent_path(); 222 3 : if (parent.empty()) return ESYSREPO_RESULT(ResultCode::OK); 223 3 : boost::system::error_code ec; 224 3 : boost::filesystem::create_directories(parent, ec); 225 3 : if (ec) return ESYSREPO_RESULT(ResultCode::FOLDER_CREATION_ERROR, parent.string()); 226 3 : return ESYSREPO_RESULT(ResultCode::OK); 227 3 : } 228 : 229 4 : Result Materializer::warn_revision_mismatch(const std::string &required_commit) 230 : { 231 4 : m_last_mismatch_warning.clear(); 232 4 : if (!m_git) return ESYSREPO_RESULT(ResultCode::GIT_IS_NULLPTR); 233 4 : git::CommitHash head; 234 4 : Result r = m_git->get_last_commit(head); 235 4 : if (r.error()) return ESYSREPO_RESULT(ResultCode::OK); // do not fail materialize on head query 236 4 : if (commit_prefix_equal(head.get_hash(), required_commit)) return ESYSREPO_RESULT(ResultCode::OK); 237 : 238 4 : m_last_mismatch_warning = "shared worktree required commit " + required_commit + 239 4 : " differs from provider HEAD " + head.get_hash(); 240 2 : warn(m_last_mismatch_warning); 241 2 : return ESYSREPO_RESULT(ResultCode::OK); 242 4 : } 243 : 244 8 : Result Materializer::materialize(const std::string &dest_abs, const std::string &required_commit) 245 : { 246 8 : m_last_mismatch_warning.clear(); 247 8 : if (!m_git) return ESYSREPO_RESULT(ResultCode::GIT_IS_NULLPTR); 248 8 : if (!m_git->is_open()) return ESYSREPO_RESULT(ResultCode::GIT_REPO_NOT_OPEN); 249 8 : if (required_commit.empty()) 250 0 : return ESYSREPO_RESULT(ResultCode::CMD_MISSING_PARAMETERS, "required_commit is empty"); 251 : 252 8 : Result safety = check_path_safety(dest_abs); 253 8 : if (safety.error()) return safety; 254 : 255 6 : auto has = m_git->has_object(required_commit); 256 6 : if (has.error()) return ESYSREPO_RESULT(has); 257 6 : if (!has.get()) 258 1 : return ESYSREPO_RESULT(ResultCode::SHARING_COMMIT_MISSING, 259 : "required commit not in provider object store: " + required_commit); 260 : 261 5 : const DestKind kind = classify_destination(dest_abs); 262 5 : switch (kind) 263 : { 264 0 : case DestKind::InitializedSubmodule: 265 0 : return ESYSREPO_RESULT(ResultCode::SHARING_SUBMODULE_INITIALIZED, 266 : "destination looks like an initialized submodule; deinit/remove before sharing"); 267 0 : case DestKind::ReparsePoint: 268 0 : return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, "destination is a reparse point / symlink"); 269 1 : case DestKind::StandaloneRepo: 270 1 : case DestKind::NonEmptyUnknown: 271 1 : return ESYSREPO_RESULT(ResultCode::SHARING_UNSAFE_PATH, 272 : "destination exists and is not a managed linked worktree"); 273 2 : case DestKind::LinkedWorktree: 274 2 : { 275 : // Idempotent if HEAD matches; else remove and recreate 276 2 : std::vector<git::Worktree> trees; 277 2 : Result lr = m_git->worktree_list(trees); 278 2 : if (lr.error()) return lr; 279 6 : const auto dest_n = abs_norm(dest_abs).lexically_normal().generic_string(); 280 2 : std::string listed_head; 281 4 : for (const auto &wt : trees) 282 : { 283 12 : auto p = abs_norm(wt.get_path()).lexically_normal().generic_string(); 284 4 : if (p == dest_n) 285 : { 286 2 : listed_head = wt.get_head(); 287 2 : break; 288 : } 289 4 : } 290 2 : if (!listed_head.empty() && commit_prefix_equal(listed_head, required_commit)) 291 : { 292 1 : warn_revision_mismatch(required_commit); 293 1 : return ESYSREPO_RESULT(ResultCode::OK); 294 : } 295 1 : Result rr = m_git->worktree_remove(dest_abs, true); 296 1 : if (rr.error()) return rr; 297 1 : break; 298 3 : } 299 0 : case DestKind::EmptyDir: 300 0 : { 301 0 : boost::system::error_code ec; 302 0 : boost::filesystem::remove(abs_norm(dest_abs), ec); 303 0 : break; 304 : } 305 : case DestKind::Missing: break; 306 : } 307 : 308 6 : Result parent = ensure_parent_dir(dest_abs); 309 3 : if (parent.error()) return parent; 310 : 311 6 : Result add = m_git->worktree_add_detach(dest_abs, required_commit); 312 3 : if (add.error()) return add; 313 : 314 3 : warn_revision_mismatch(required_commit); 315 3 : return ESYSREPO_RESULT(ResultCode::OK); 316 8 : } 317 : 318 : } // namespace esys::repo::sharing