archive.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """``muse archive`` — export a snapshot as a portable archive. |
| 2 | |
| 3 | Creates a ``tar.gz`` or ``zip`` archive from any historical snapshot — |
| 4 | HEAD by default. The archive contains only the tracked files (the contents |
| 5 | of ``state/`` at that point in time), making it the canonical way to share |
| 6 | a specific version without exposing the ``.muse/`` internals. |
| 7 | |
| 8 | Usage:: |
| 9 | |
| 10 | muse archive # HEAD snapshot → archive.tar.gz |
| 11 | muse archive --ref feat/audio # branch tip |
| 12 | muse archive --ref a1b2c3d4 # specific commit SHA prefix |
| 13 | muse archive --format zip # zip instead of tar.gz |
| 14 | muse archive --output release-v1.0.zip # custom output path |
| 15 | muse archive --prefix myproject/ # add a directory prefix inside the archive |
| 16 | muse archive --json # machine-readable output on stdout |
| 17 | |
| 18 | The archive is purely content — no Muse metadata (``.muse/``) is included. |
| 19 | This is intentional: archives are for *distribution*, not collaboration. |
| 20 | Use ``muse push`` / ``muse clone`` for distribution with full history. |
| 21 | |
| 22 | JSON output (``--json``) schema:: |
| 23 | |
| 24 | { |
| 25 | "path": "<output file path>", |
| 26 | "format": "tar.gz" | "zip", |
| 27 | "file_count": <int>, |
| 28 | "bytes": <int>, |
| 29 | "commit_id": "<sha256>", |
| 30 | "message": "<commit message>", |
| 31 | "branch": "<branch>", |
| 32 | "ref": "<ref used or null>" |
| 33 | } |
| 34 | |
| 35 | Exit codes:: |
| 36 | |
| 37 | 0 — success |
| 38 | 1 — bad arguments (bad format, missing commits, bad prefix) |
| 39 | 2 — internal error (missing snapshot or object) |
| 40 | """ |
| 41 | |
| 42 | from __future__ import annotations |
| 43 | |
| 44 | import argparse |
| 45 | import json |
| 46 | import logging |
| 47 | import pathlib |
| 48 | import sys |
| 49 | import tarfile |
| 50 | import zipfile |
| 51 | from typing import TypedDict |
| 52 | |
| 53 | from muse.core.errors import ExitCode |
| 54 | from muse.core.object_store import object_path |
| 55 | from muse.core.repo import read_repo_id, require_repo |
| 56 | from muse.core.store import ( |
| 57 | get_head_commit_id, |
| 58 | read_commit, |
| 59 | read_current_branch, |
| 60 | read_snapshot, |
| 61 | resolve_commit_ref, |
| 62 | ) |
| 63 | from muse.core.validation import sanitize_display |
| 64 | from muse.core._types import Manifest |
| 65 | |
| 66 | logger = logging.getLogger(__name__) |
| 67 | |
| 68 | _FORMAT_CHOICES = {"tar.gz", "zip"} |
| 69 | |
| 70 | |
| 71 | # --------------------------------------------------------------------------- |
| 72 | # Typed JSON schema |
| 73 | # --------------------------------------------------------------------------- |
| 74 | |
| 75 | |
| 76 | class _ArchiveJson(TypedDict): |
| 77 | """Machine-readable output of ``muse archive --json``.""" |
| 78 | |
| 79 | path: str |
| 80 | format: str |
| 81 | file_count: int |
| 82 | bytes: int |
| 83 | commit_id: str |
| 84 | message: str |
| 85 | branch: str |
| 86 | ref: str | None |
| 87 | |
| 88 | |
| 89 | # --------------------------------------------------------------------------- |
| 90 | # Path safety |
| 91 | # --------------------------------------------------------------------------- |
| 92 | |
| 93 | |
| 94 | def _safe_arcname(prefix: str, rel_path: str) -> str | None: |
| 95 | """Build a safe archive entry name, guarding against zip-slip/tar-slip. |
| 96 | |
| 97 | Returns ``None`` if either *prefix* or *rel_path* is unsafe or empty; |
| 98 | the caller must skip those entries. |
| 99 | |
| 100 | Safety rules enforced: |
| 101 | |
| 102 | - *rel_path* must be non-empty and must not be ``"."`` after normalisation. |
| 103 | - *rel_path* must not be absolute. |
| 104 | - Neither *prefix* nor *rel_path* may contain ``..`` path segments. |
| 105 | - Null bytes are rejected (they can confuse archive readers). |
| 106 | """ |
| 107 | if not rel_path or "\x00" in rel_path or "\x00" in prefix: |
| 108 | return None |
| 109 | |
| 110 | clean_prefix = prefix.rstrip("/").strip() |
| 111 | if clean_prefix and ".." in clean_prefix.split("/"): |
| 112 | return None |
| 113 | |
| 114 | resolved = pathlib.PurePosixPath(rel_path) |
| 115 | if resolved.is_absolute() or ".." in resolved.parts: |
| 116 | return None |
| 117 | |
| 118 | safe_rel = str(resolved) |
| 119 | # PurePosixPath("") normalises to "." — reject it. |
| 120 | if not safe_rel or safe_rel == ".": |
| 121 | return None |
| 122 | |
| 123 | return (clean_prefix + "/" + safe_rel) if clean_prefix else safe_rel |
| 124 | |
| 125 | |
| 126 | # --------------------------------------------------------------------------- |
| 127 | # Archive builders |
| 128 | # --------------------------------------------------------------------------- |
| 129 | |
| 130 | |
| 131 | def _build_tar( |
| 132 | root: pathlib.Path, |
| 133 | manifest: Manifest, |
| 134 | output_path: pathlib.Path, |
| 135 | prefix: str, |
| 136 | ) -> int: |
| 137 | """Write a ``tar.gz`` archive from *manifest*; return the file count. |
| 138 | |
| 139 | Each entry's archive name is validated by ``_safe_arcname`` to prevent |
| 140 | tar-slip path traversal attacks. Unsafe entries are logged and skipped. |
| 141 | """ |
| 142 | count = 0 |
| 143 | with tarfile.open(output_path, "w:gz") as tar: |
| 144 | for rel_path, object_id in sorted(manifest.items()): |
| 145 | arcname = _safe_arcname(prefix, rel_path) |
| 146 | if arcname is None: |
| 147 | logger.warning( |
| 148 | "⚠️ Skipping unsafe archive path: %s", |
| 149 | sanitize_display(rel_path), |
| 150 | ) |
| 151 | continue |
| 152 | obj = object_path(root, object_id) |
| 153 | if not obj.exists(): |
| 154 | logger.warning( |
| 155 | "⚠️ Missing object %s for %s — skipping", |
| 156 | object_id[:12], |
| 157 | sanitize_display(rel_path), |
| 158 | ) |
| 159 | continue |
| 160 | tar.add(str(obj), arcname=arcname, recursive=False) |
| 161 | count += 1 |
| 162 | return count |
| 163 | |
| 164 | |
| 165 | def _build_zip( |
| 166 | root: pathlib.Path, |
| 167 | manifest: Manifest, |
| 168 | output_path: pathlib.Path, |
| 169 | prefix: str, |
| 170 | ) -> int: |
| 171 | """Write a ``zip`` archive from *manifest*; return the file count. |
| 172 | |
| 173 | Each entry's archive name is validated by ``_safe_arcname`` to prevent |
| 174 | zip-slip path traversal attacks. Unsafe entries are logged and skipped. |
| 175 | """ |
| 176 | count = 0 |
| 177 | with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: |
| 178 | for rel_path, object_id in sorted(manifest.items()): |
| 179 | arcname = _safe_arcname(prefix, rel_path) |
| 180 | if arcname is None: |
| 181 | logger.warning( |
| 182 | "⚠️ Skipping unsafe archive path: %s", |
| 183 | sanitize_display(rel_path), |
| 184 | ) |
| 185 | continue |
| 186 | obj = object_path(root, object_id) |
| 187 | if not obj.exists(): |
| 188 | logger.warning( |
| 189 | "⚠️ Missing object %s for %s — skipping", |
| 190 | object_id[:12], |
| 191 | sanitize_display(rel_path), |
| 192 | ) |
| 193 | continue |
| 194 | zf.write(str(obj), arcname=arcname) |
| 195 | count += 1 |
| 196 | return count |
| 197 | |
| 198 | |
| 199 | # --------------------------------------------------------------------------- |
| 200 | # Registration |
| 201 | # --------------------------------------------------------------------------- |
| 202 | |
| 203 | |
| 204 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 205 | """Register the ``archive`` subcommand.""" |
| 206 | parser = subparsers.add_parser( |
| 207 | "archive", |
| 208 | help="Export any historical snapshot as a portable archive.", |
| 209 | description=__doc__, |
| 210 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 211 | ) |
| 212 | parser.add_argument( |
| 213 | "--ref", |
| 214 | default=None, |
| 215 | help="Branch, tag, or commit SHA to archive (default: HEAD).", |
| 216 | ) |
| 217 | parser.add_argument( |
| 218 | "--format", "-f", |
| 219 | default="tar.gz", |
| 220 | dest="fmt", |
| 221 | choices=sorted(_FORMAT_CHOICES), |
| 222 | help="Archive format: tar.gz or zip (default: tar.gz).", |
| 223 | ) |
| 224 | parser.add_argument( |
| 225 | "--output", "-o", |
| 226 | default=None, |
| 227 | help="Output file path (default: <sha12>.<format>).", |
| 228 | ) |
| 229 | parser.add_argument( |
| 230 | "--prefix", |
| 231 | default="", |
| 232 | help="Directory prefix inside the archive (e.g. myproject/).", |
| 233 | ) |
| 234 | parser.add_argument( |
| 235 | "--json", |
| 236 | action="store_true", |
| 237 | dest="output_json", |
| 238 | default=False, |
| 239 | help="Emit machine-readable JSON to stdout instead of human text.", |
| 240 | ) |
| 241 | parser.set_defaults(func=run) |
| 242 | |
| 243 | |
| 244 | # --------------------------------------------------------------------------- |
| 245 | # Command implementation |
| 246 | # --------------------------------------------------------------------------- |
| 247 | |
| 248 | |
| 249 | def run(args: argparse.Namespace) -> None: |
| 250 | """Export any historical snapshot as a portable archive. |
| 251 | |
| 252 | The archive contains only tracked files — no ``.muse/`` metadata. It is |
| 253 | the canonical distribution format for a specific version. |
| 254 | |
| 255 | Agents should pass ``--json`` to receive a machine-readable result with |
| 256 | ``path``, ``format``, ``file_count``, ``bytes``, ``commit_id``, |
| 257 | ``message``, ``branch``, and ``ref``. |
| 258 | |
| 259 | Examples:: |
| 260 | |
| 261 | muse archive # HEAD → <sha12>.tar.gz |
| 262 | muse archive --ref v1.0.0 # tag → <sha12>.tar.gz |
| 263 | muse archive --format zip --output dist/release.zip |
| 264 | muse archive --prefix myproject/ # all files under myproject/ |
| 265 | muse archive --json # machine-readable |
| 266 | """ |
| 267 | ref: str | None = args.ref |
| 268 | fmt: str = args.fmt |
| 269 | output: str | None = args.output |
| 270 | prefix: str = args.prefix |
| 271 | output_json: bool = args.output_json |
| 272 | |
| 273 | # Validate prefix against traversal — _safe_arcname will also check |
| 274 | # per-entry, but an early explicit check gives the user a clear message. |
| 275 | clean_prefix = prefix.rstrip("/").strip() |
| 276 | if clean_prefix and ".." in clean_prefix.split("/"): |
| 277 | print( |
| 278 | f"❌ --prefix must not contain '..' segments: {sanitize_display(prefix)}", |
| 279 | file=sys.stderr, |
| 280 | ) |
| 281 | raise SystemExit(ExitCode.USER_ERROR) |
| 282 | |
| 283 | root = require_repo() |
| 284 | repo_id = read_repo_id(root) |
| 285 | branch = read_current_branch(root) |
| 286 | |
| 287 | if ref is None: |
| 288 | commit_id = get_head_commit_id(root, branch) |
| 289 | if not commit_id: |
| 290 | print("❌ No commits yet on this branch.", file=sys.stderr) |
| 291 | raise SystemExit(ExitCode.USER_ERROR) |
| 292 | commit = read_commit(root, commit_id) |
| 293 | else: |
| 294 | commit = resolve_commit_ref(root, repo_id, branch, ref) |
| 295 | |
| 296 | if commit is None: |
| 297 | print( |
| 298 | f"❌ Ref '{sanitize_display(ref or 'HEAD')}' not found.", |
| 299 | file=sys.stderr, |
| 300 | ) |
| 301 | raise SystemExit(ExitCode.USER_ERROR) |
| 302 | |
| 303 | snapshot = read_snapshot(root, commit.snapshot_id) |
| 304 | if snapshot is None: |
| 305 | print( |
| 306 | f"❌ Snapshot {commit.snapshot_id[:8]} not found.", |
| 307 | file=sys.stderr, |
| 308 | ) |
| 309 | raise SystemExit(ExitCode.INTERNAL_ERROR) |
| 310 | |
| 311 | short = commit.commit_id[:12] |
| 312 | out_name = output or f"{short}.{fmt}" |
| 313 | out_path = pathlib.Path(out_name) |
| 314 | |
| 315 | if fmt == "tar.gz": |
| 316 | count = _build_tar(root, snapshot.manifest, out_path, prefix) |
| 317 | else: |
| 318 | count = _build_zip(root, snapshot.manifest, out_path, prefix) |
| 319 | |
| 320 | archive_bytes = out_path.stat().st_size if out_path.exists() else 0 |
| 321 | |
| 322 | if output_json: |
| 323 | payload = _ArchiveJson( |
| 324 | path=str(out_path), |
| 325 | format=fmt, |
| 326 | file_count=count, |
| 327 | bytes=archive_bytes, |
| 328 | commit_id=commit.commit_id, |
| 329 | message=commit.message, |
| 330 | branch=branch, |
| 331 | ref=ref, |
| 332 | ) |
| 333 | print(json.dumps(payload)) |
| 334 | return |
| 335 | |
| 336 | size_kb = archive_bytes / 1024 |
| 337 | print( |
| 338 | f"✅ Archive: {out_path} ({count} file(s), {size_kb:.1f} KiB)\n" |
| 339 | f" Commit: {short} {sanitize_display(commit.message)}" |
| 340 | ) |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago