repo.py python
448 lines 17.2 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago
1 """Repository detection utilities for the Muse CLI.
2
3 Walking up the directory tree to locate a ``.muse/`` directory is the
4 single most-called internal primitive. Every subcommand uses it. Keeping
5 the semantics clear (``None`` on miss, never raises) makes callers simpler
6 and test isolation easier (``MUSE_REPO_ROOT`` env-var override).
7
8 :func:`read_repo_id` is the canonical way to read the repository ID from
9 ``.muse/repo.json``. It replaces 73 copy-pasted ``_read_repo_id`` functions
10 that had diverged into four different error-handling behavioural variants.
11 The canonical implementation uses ``REPO_NOT_FOUND`` on a missing file and
12 ``INTERNAL_ERROR`` on a malformed file — errors go through the logger, not
13 stderr, so commands that want to emit a user-facing message can catch the
14 ``SystemExit`` and print their own context.
15
16 :func:`require_repo` performs a **startup GC sweep** on every invocation,
17 removing stale temp files left by a prior SIGKILL. All three temp-file
18 families used by Muse are covered:
19
20 * ``.obj-tmp-*`` / ``.restore-tmp-*`` — object-store shard directories
21 (delegated to :func:`muse.core.object_store.cleanup_stale_object_temps`)
22 * ``.muse-tmp-*`` — created by :func:`~muse.core.store.write_text_atomic`
23 and :func:`~muse.core.store._write_msgpack_atomic` in ``.muse/`` and its
24 subdirectories (branches, commits, snapshots, tags, releases, …)
25 * ``.stat_cache_*.tmp`` — created by :class:`~muse.core.stat_cache.StatCache`
26 in ``.muse/``
27 """
28
29 from __future__ import annotations
30
31 import datetime
32 import json
33 import logging
34 import os
35 import pathlib
36 import sys
37
38 from muse.core.errors import ExitCode, UntrustedRepositoryError
39 from muse.core.validation import assert_not_symlink
40
41 logger = logging.getLogger(__name__)
42
43 # Subdirectories of .muse/ (excluding objects/) that can hold stale temps.
44 # objects/ is handled separately by cleanup_stale_object_temps.
45 _MUSE_SWEEP_DIRS: tuple[str, ...] = (
46 "", # .muse/ root itself (HEAD, stat_cache, config, merge-state, etc.)
47 "branches", # write_branch_ref
48 "commits", # write_commit via _write_msgpack_atomic
49 "snapshots", # write_snapshot via _write_msgpack_atomic
50 "tags", # write_tag via _write_msgpack_atomic
51 "releases", # write_release via _write_msgpack_atomic
52 "refs/heads", # write_branch_ref alternative path
53 "code", # code-domain index writes
54 "indices", # index writes
55 "coordination", # create_intent / create_reservation
56 "worktrees", # _save_meta
57 "stash", # stash writes
58 "rr-cache", # reuse-recorded resolution cache
59 "logs", # log writes
60 "remotes", # remote config writes
61 )
62
63 # File-name prefixes that identify stale temps within the above directories.
64 # .muse-tmp-: write_text_atomic + _write_msgpack_atomic
65 # .stat_cache_: StatCache.save (suffix .tmp, but prefix is unique enough)
66 _MUSE_TEMP_PREFIXES: tuple[str, ...] = (".muse-tmp-", ".stat_cache_")
67
68
69 def _cleanup_muse_dir_temps(muse_dir: pathlib.Path) -> int:
70 """Remove stale ``.muse-tmp-*`` and ``.stat_cache_*.tmp`` files.
71
72 Iterates only the known subdirectory set that Muse writes to, so the
73 object store (handled separately) and user files in the workdir are
74 never touched.
75
76 Returns:
77 Number of stale temp files removed.
78 """
79 if not muse_dir.is_dir():
80 return 0
81 removed = 0
82 for subdir in _MUSE_SWEEP_DIRS:
83 target = muse_dir / subdir if subdir else muse_dir
84 # Skip symlinked subdirectories — never delete files inside an
85 # attacker-controlled location that was swapped in via symlink.
86 if not target.is_dir() or target.is_symlink():
87 continue
88 for entry in target.iterdir():
89 if entry.is_file() and any(
90 entry.name.startswith(pfx) for pfx in _MUSE_TEMP_PREFIXES
91 ):
92 try:
93 entry.unlink()
94 removed += 1
95 logger.warning(
96 "⚠️ Removed stale muse temp %s (left by prior crash)", entry
97 )
98 except OSError as exc:
99 logger.warning(
100 "⚠️ Could not remove stale temp %s: %s", entry, exc
101 )
102 return removed
103
104
105 _CRITICAL_MUSE_DIRS: tuple[str, ...] = (
106 "objects",
107 "commits",
108 "snapshots",
109 "refs",
110 "refs/heads",
111 "tags",
112 )
113
114
115 def _verify_muse_dir_integrity(muse_dir: pathlib.Path) -> None:
116 """Assert that critical ``.muse/`` subdirectories are real directories.
117
118 Any of these being a symlink would redirect writes to an attacker-
119 controlled location. Called by :func:`require_repo` on every invocation
120 so the check runs at the trust boundary, before any store operation.
121
122 Args:
123 muse_dir: Absolute path to the ``.muse/`` directory.
124
125 Raises:
126 SystemExit(1): If any critical subdirectory is a symbolic link.
127 """
128 for subname in _CRITICAL_MUSE_DIRS:
129 candidate = muse_dir / subname
130 if not candidate.exists():
131 continue # not yet created — first-use, not an attack
132 try:
133 assert_not_symlink(candidate, label=f".muse/{subname}")
134 except ValueError as exc:
135 logger.error("❌ %s", exc)
136 raise SystemExit(1) from exc
137
138
139 def _startup_gc(repo_root: pathlib.Path) -> None:
140 """Sweep all stale temp files left by a prior SIGKILL crash.
141
142 Called by :func:`require_repo` on every command invocation so that any
143 orphaned temp file from the previous crash is cleaned before the current
144 command reads or writes the store. The sweep is fast (< 5 ms on a
145 typical repo) because it only touches small, bounded directories.
146
147 Three temp-file families are covered:
148
149 1. Object-store temps (``.obj-tmp-*``, ``.restore-tmp-*``) via
150 :func:`~muse.core.object_store.cleanup_stale_object_temps`.
151 2. Store/config temps (``.muse-tmp-*``) via
152 :func:`_cleanup_muse_dir_temps`.
153 3. Stat-cache temps (``.stat_cache_*.tmp``) via the same sweep (the
154 ``.stat_cache_`` prefix is included in :data:`_MUSE_TEMP_PREFIXES`).
155 """
156 from muse.core.object_store import cleanup_stale_object_temps
157
158 cleanup_stale_object_temps(repo_root)
159 _cleanup_muse_dir_temps(repo_root / ".muse")
160
161
162 def _resolve_worktree_pointer(pointer_path: pathlib.Path) -> pathlib.Path | None:
163 """Read a ``.muse`` worktree pointer file and return the main repo root.
164
165 The file must contain a line of the form::
166
167 musestore: /absolute/path/to/main/.muse
168
169 Returns the parent of the ``.muse/`` store (i.e. the main repo root), or
170 ``None`` on any parse or validation failure. Never raises.
171 """
172 try:
173 text = pointer_path.read_text(encoding="utf-8", errors="strict").strip()
174 except Exception as exc:
175 logger.debug("Could not read worktree pointer %s: %s", pointer_path, exc)
176 return None
177
178 prefix = "musestore: "
179 if not text.startswith(prefix):
180 logger.debug("Worktree pointer %s has unexpected format: %r", pointer_path, text[:80])
181 return None
182
183 raw_store = text[len(prefix):].strip()
184 if not raw_store:
185 return None
186 if any(ord(c) < 0x20 or ord(c) == 0x7F for c in raw_store):
187 logger.warning("⚠️ Worktree pointer %s contains control characters — ignoring", pointer_path)
188 return None
189 if len(raw_store) > 4096:
190 logger.warning("⚠️ Worktree pointer %s path too long — ignoring", pointer_path)
191 return None
192
193 store_path = pathlib.Path(raw_store).resolve()
194 if not store_path.is_dir() or store_path.is_symlink():
195 logger.debug("Worktree pointer %s → %s is not a valid store dir", pointer_path, store_path)
196 return None
197
198 repo_root = store_path.parent
199 # Loop guard: resolved root must not be the worktree directory itself.
200 worktree_dir = pointer_path.parent.resolve()
201 if repo_root == worktree_dir:
202 logger.warning("⚠️ Worktree pointer %s loops back to its own directory", pointer_path)
203 return None
204
205 return repo_root
206
207
208 def _is_repo_trusted(repo_root: pathlib.Path) -> bool:
209 """Return ``True`` if *repo_root* is in the caller's trust list.
210
211 Checks two sources (in order):
212 1. ``MUSE_SAFE_DIRS`` environment variable — colon-separated absolute paths.
213 2. ``~/.muse/config.toml`` ``[security] safe_dirs`` list.
214
215 Root (uid == 0) is always trusted.
216 """
217 if os.getuid() == 0:
218 return True
219
220 canonical = str(repo_root.resolve())
221
222 # 1. MUSE_SAFE_DIRS env var (colon-separated, for Docker/CI).
223 env_raw = os.environ.get("MUSE_SAFE_DIRS", "")
224 if env_raw.strip():
225 for raw_dir in env_raw.split(":"):
226 if raw_dir.strip() and pathlib.Path(raw_dir.strip()).resolve() == pathlib.Path(canonical):
227 return True
228
229 # 2. ~/.muse/config.toml [security] safe_dirs.
230 try:
231 from muse.cli.config import get_global_safe_dirs
232 for safe_path in get_global_safe_dirs():
233 if pathlib.Path(safe_path).resolve() == pathlib.Path(canonical):
234 return True
235 except Exception: # noqa: BLE001
236 pass
237
238 return False
239
240
241 def _check_repo_ownership(repo_root: pathlib.Path) -> None:
242 """Raise :class:`~muse.core.errors.UntrustedRepositoryError` if ownership mismatch.
243
244 Implements a CVE-2022-24765–equivalent check: the ``.muse/`` directory must
245 be owned by the current user, or the repository must be explicitly trusted.
246
247 Skipped when:
248 - Current uid is 0 (root has unrestricted access anyway).
249 - The repo is in ``MUSE_SAFE_DIRS`` or ``~/.muse/config.toml`` safe_dirs.
250
251 Args:
252 repo_root: The repository root directory (parent of ``.muse/``).
253
254 Raises:
255 UntrustedRepositoryError: When owner UID does not match current UID and
256 the path is not in the trust list.
257 """
258 current_uid = os.getuid()
259 if current_uid == 0:
260 return # root bypass
261
262 muse_dir = repo_root / ".muse"
263 try:
264 st = muse_dir.stat()
265 except OSError:
266 # Can't stat — not a concern for ownership check; other code handles
267 # missing .muse/
268 return
269
270 owner_uid = st.st_uid
271 if owner_uid == current_uid:
272 return # owned by us — safe
273
274 # Different owner — check trust list before raising.
275 if _is_repo_trusted(repo_root):
276 return
277
278 raise UntrustedRepositoryError(
279 path=str(repo_root),
280 owner_uid=owner_uid,
281 current_uid=current_uid,
282 )
283
284
285 def find_repo_root(start: pathlib.Path | None = None) -> pathlib.Path | None:
286 """Walk up from *start* (default ``Path.cwd()``) looking for ``.muse/``.
287
288 Returns the first directory that contains ``.muse/``, or ``None`` if no
289 such ancestor exists. Never raises — callers decide what to do on miss.
290
291 The ``MUSE_REPO_ROOT`` environment variable overrides discovery entirely;
292 set it in tests to avoid ``os.chdir`` calls.
293
294 Security hardening for ``MUSE_REPO_ROOT``:
295 - Empty or whitespace-only values are silently ignored (falls through to
296 directory walk) rather than being resolved to the current working
297 directory, which would bypass the explicit intent to override.
298 - Values longer than the OS ``PATH_MAX`` (4096 on Linux/macOS) are
299 rejected — overly long paths indicate an injection attempt.
300 - Control characters in the value are rejected — they indicate a crafted
301 payload rather than a genuine file-system path.
302 - Symlinked ``.muse/`` directories are rejected even when the path comes
303 from the env override, consistent with the directory-walk path.
304
305 Ownership check (CVE-2022-24765 equivalent):
306 - After locating ``.muse/``, ``_check_repo_ownership`` verifies that the
307 directory is owned by the current user.
308 - Raises :class:`~muse.core.errors.UntrustedRepositoryError` on mismatch
309 unless the path is in ``MUSE_SAFE_DIRS`` or ``~/.muse/config.toml``.
310 """
311 raw_env = os.environ.get("MUSE_REPO_ROOT")
312 if raw_env is not None:
313 # Silently ignore empty or whitespace-only values — fall through to walk.
314 stripped = raw_env.strip()
315 if not stripped:
316 logger.debug("MUSE_REPO_ROOT is empty or whitespace — ignoring, using cwd walk")
317 else:
318 # Reject values containing control characters.
319 if any(ord(c) < 0x20 or ord(c) == 0x7F for c in stripped):
320 logger.warning(
321 "⚠️ MUSE_REPO_ROOT contains control characters — ignoring for safety"
322 )
323 return None
324 # Reject unreasonably long paths (OS PATH_MAX is 4096 on Linux/macOS).
325 if len(stripped) > 4096:
326 logger.warning(
327 "⚠️ MUSE_REPO_ROOT is too long (%d chars) — ignoring for safety",
328 len(stripped),
329 )
330 return None
331 p = pathlib.Path(stripped).resolve()
332 logger.debug("⚠️ MUSE_REPO_ROOT override active: %s", p)
333 muse_candidate = p / ".muse"
334 # Reject symlinked .muse/ even when the path comes from the env override.
335 if muse_candidate.is_dir() and not muse_candidate.is_symlink():
336 _check_repo_ownership(p)
337 return p
338 return None
339
340 current = (start or pathlib.Path.cwd()).resolve()
341 while True:
342 muse_dir = current / ".muse"
343 # Reject symlinked .muse/ — a symlink here redirects all subsequent
344 # writes to an attacker-controlled location outside the repo root.
345 if muse_dir.is_dir() and not muse_dir.is_symlink():
346 _check_repo_ownership(current)
347 return current
348 # Linked worktree: .muse is a file containing "musestore: /path/to/.muse"
349 if muse_dir.is_file() and not muse_dir.is_symlink():
350 resolved = _resolve_worktree_pointer(muse_dir)
351 if resolved is not None:
352 _check_repo_ownership(resolved)
353 return resolved
354 parent = current.parent
355 if parent == current:
356 return None
357 current = parent
358
359
360 _NOT_A_REPO_MSG = (
361 'fatal: not a muse repository (or any parent up to mount point /)\n'
362 'Run "muse init" to initialize a new repository.'
363 )
364
365
366 def require_repo(start: pathlib.Path | None = None) -> pathlib.Path:
367 """Return the repo root or exit 2 with a clear error message.
368
369 Wraps ``find_repo_root()`` for command callbacks that must be inside a
370 Muse repository. The error text is written to stderr so the shell always
371 surfaces it; our ``CliRunner`` merges stderr into ``result.output``.
372
373 **Startup GC sweep:** after locating the repo root, performs a lightweight
374 sweep of all ``.muse/`` subdirectories to remove stale temp files left by
375 a prior ``SIGKILL``. The sweep covers all three temp-file families
376 produced by the store layer (``.muse-tmp-*``, ``.stat_cache_*.tmp``,
377 ``.obj-tmp-*``, ``.restore-tmp-*``). The cost is < 5 ms on a typical
378 repo because only small, bounded directories are listed.
379 """
380 root = find_repo_root(start)
381 if root is None:
382 print(_NOT_A_REPO_MSG, file=sys.stderr)
383 raise SystemExit(ExitCode.REPO_NOT_FOUND)
384 _verify_muse_dir_integrity(root / ".muse")
385 _startup_gc(root)
386 return root
387
388
389 #: Public alias.
390 require_repo_root = require_repo
391
392
393 def parse_date_arg(value: str, flag: str) -> datetime.datetime:
394 """Parse an ISO-8601 date or datetime string from a CLI flag.
395
396 Accepts ``YYYY-MM-DD`` and ``YYYY-MM-DDTHH:MM:SS``. Always returns a
397 UTC-aware :class:`datetime.datetime`. Exits with code 1 and a clear
398 error message on parse failure, naming the offending *flag*.
399
400 This is the canonical implementation replacing per-command inline date
401 parsing that used different formats and error messages.
402
403 Args:
404 value: The raw string value from the CLI argument.
405 flag: The flag name (e.g. ``--since``) used in the error message.
406
407 Returns:
408 A UTC-aware :class:`datetime.datetime`.
409
410 Raises:
411 SystemExit(1): when *value* cannot be parsed as a recognised format.
412 """
413 for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
414 try:
415 return datetime.datetime.strptime(value, fmt).replace(
416 tzinfo=datetime.timezone.utc
417 )
418 except ValueError:
419 continue
420 print(
421 f"❌ Invalid date for {flag}: {value!r}"
422 " — expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS",
423 file=sys.stderr,
424 )
425 raise SystemExit(1)
426
427
428 def read_repo_id(repo_root: pathlib.Path) -> str:
429 """Read the ``repo_id`` from ``.muse/repo.json``.
430
431 This is the canonical implementation. It replaces 73 copy-pasted
432 ``_read_repo_id`` functions that had diverged into four different
433 error-handling variants across the codebase.
434
435 Raises:
436 SystemExit(REPO_NOT_FOUND): when ``.muse/repo.json`` does not exist.
437 SystemExit(INTERNAL_ERROR): when the file exists but is not valid JSON
438 or does not contain the expected ``repo_id`` key.
439 """
440 repo_json = repo_root / ".muse" / "repo.json"
441 try:
442 return str(json.loads(repo_json.read_text(encoding="utf-8"))["repo_id"])
443 except FileNotFoundError as exc:
444 logger.debug(".muse/repo.json not found: %s", exc)
445 raise SystemExit(ExitCode.REPO_NOT_FOUND) from exc
446 except (json.JSONDecodeError, KeyError) as exc:
447 logger.debug(".muse/repo.json malformed: %s", exc)
448 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 13 days ago