repo.py
python
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