| 1 | """swaypack tarball → ready-to-run directory tree (Sprint 26 / X3). |
| 2 | |
| 3 | Inverse of :mod:`dlm_sway.cli._pack`. Given a ``*.swaypack.tar.gz``, |
| 4 | extract its contents into a target dir, validate the manifest, and |
| 5 | return enough info for ``sway unpack`` to print actionable next-step |
| 6 | instructions ("``cd <dir> && SWAY_NULL_CACHE_DIR=<dir>/null-stats sway run sway.yaml``"). |
| 7 | |
| 8 | Path-traversal hardening: ``tarfile.extractall`` defaults to ``filter='data'`` |
| 9 | on Python 3.12+, which already rejects absolute paths and ``../`` |
| 10 | escape attempts. We pin that filter explicitly so the safety holds |
| 11 | on 3.11 (which we support per ``pyproject.toml``) and isn't a |
| 12 | rolling-mean-of-warnings trap on future versions. |
| 13 | """ |
| 14 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import json |
| 18 | import tarfile |
| 19 | from dataclasses import dataclass |
| 20 | from pathlib import Path |
| 21 | |
| 22 | from dlm_sway.core.errors import SwayError |
| 23 | |
| 24 | |
| 25 | class UnpackError(SwayError): |
| 26 | """Raised when ``sway unpack`` can't extract a usable swaypack.""" |
| 27 | |
| 28 | |
| 29 | @dataclass(frozen=True, slots=True) |
| 30 | class UnpackReport: |
| 31 | """Result of a successful unpack call. |
| 32 | |
| 33 | Attributes |
| 34 | ---------- |
| 35 | out_dir: |
| 36 | Where the contents were extracted (``<target>/swaypack/``). |
| 37 | spec_path: |
| 38 | Convenience pointer to ``out_dir / "sway.yaml"``. |
| 39 | null_stats_dir: |
| 40 | Path to the unpacked null-stats cache, or ``None`` when the |
| 41 | pack didn't bundle one. Callers set ``SWAY_NULL_CACHE_DIR`` |
| 42 | to this value before running ``sway run`` to honor the |
| 43 | bundled stats. |
| 44 | manifest: |
| 45 | Parsed ``manifest.json`` — useful for diagnostics. |
| 46 | """ |
| 47 | |
| 48 | out_dir: Path |
| 49 | spec_path: Path |
| 50 | null_stats_dir: Path | None |
| 51 | manifest: dict[str, object] |
| 52 | |
| 53 | |
| 54 | def unpack_swaypack(pack_path: Path, *, target_dir: Path) -> UnpackReport: |
| 55 | """Extract a swaypack into ``target_dir``. |
| 56 | |
| 57 | Parameters |
| 58 | ---------- |
| 59 | pack_path: |
| 60 | Path to a ``.swaypack.tar.gz`` produced by :func:`pack_spec`. |
| 61 | target_dir: |
| 62 | Parent directory to extract into. The pack's contents land |
| 63 | at ``target_dir / "swaypack/"`` (the tarball's top-level |
| 64 | directory). Must not already contain a ``swaypack/`` dir. |
| 65 | |
| 66 | Returns |
| 67 | ------- |
| 68 | UnpackReport |
| 69 | Pointers callers need to wire into a ``sway run`` invocation. |
| 70 | |
| 71 | Raises |
| 72 | ------ |
| 73 | UnpackError |
| 74 | Pack file missing / unreadable, target dir already has a |
| 75 | ``swaypack/`` subdir, manifest missing or version-incompatible. |
| 76 | """ |
| 77 | pack_path = Path(pack_path).expanduser().resolve() |
| 78 | target_dir = Path(target_dir).expanduser().resolve() |
| 79 | |
| 80 | if not pack_path.exists(): |
| 81 | raise UnpackError(f"swaypack not found: {pack_path}") |
| 82 | if not pack_path.is_file(): |
| 83 | raise UnpackError(f"swaypack must be a file, got: {pack_path}") |
| 84 | |
| 85 | out_root = target_dir / "swaypack" |
| 86 | if out_root.exists(): |
| 87 | raise UnpackError( |
| 88 | f"refusing to overwrite existing {out_root} — delete it or " |
| 89 | f"pass --out to a fresh directory" |
| 90 | ) |
| 91 | |
| 92 | target_dir.mkdir(parents=True, exist_ok=True) |
| 93 | try: |
| 94 | with tarfile.open(str(pack_path), mode="r:gz") as tar: |
| 95 | # ``filter='data'`` rejects absolute paths, ``../`` escapes, |
| 96 | # and special-device entries. Required for safe extraction |
| 97 | # of untrusted-source archives — packs may travel via |
| 98 | # email / shared drives. |
| 99 | tar.extractall(path=str(target_dir), filter="data") |
| 100 | except (tarfile.ReadError, tarfile.ExtractError, OSError) as exc: |
| 101 | raise UnpackError(f"failed to extract {pack_path}: {type(exc).__name__}: {exc}") from exc |
| 102 | |
| 103 | if not out_root.exists(): |
| 104 | raise UnpackError( |
| 105 | f"swaypack extracted but missing the expected 'swaypack/' " |
| 106 | f"directory; either {pack_path} isn't a sway pack or it " |
| 107 | f"was built by an incompatible writer" |
| 108 | ) |
| 109 | |
| 110 | manifest_path = out_root / "manifest.json" |
| 111 | if not manifest_path.exists(): |
| 112 | raise UnpackError(f"swaypack missing manifest.json (looked at {manifest_path})") |
| 113 | try: |
| 114 | manifest = json.loads(manifest_path.read_text(encoding="utf-8")) |
| 115 | except json.JSONDecodeError as exc: |
| 116 | raise UnpackError(f"manifest.json is not valid JSON: {exc}") from exc |
| 117 | |
| 118 | swaypack_version = manifest.get("swaypack_version") |
| 119 | if swaypack_version != 1: |
| 120 | raise UnpackError( |
| 121 | f"unsupported swaypack_version={swaypack_version}. This sway " |
| 122 | f"build understands swaypack_version=1 (S26)." |
| 123 | ) |
| 124 | |
| 125 | spec_path = out_root / manifest.get("spec_filename", "sway.yaml") |
| 126 | if not spec_path.exists(): |
| 127 | raise UnpackError( |
| 128 | f"manifest claims spec at {spec_path.name} but it's missing from the pack" |
| 129 | ) |
| 130 | |
| 131 | candidate_null_dir = out_root / "null-stats" |
| 132 | null_stats_dir: Path | None = candidate_null_dir if candidate_null_dir.is_dir() else None |
| 133 | |
| 134 | return UnpackReport( |
| 135 | out_dir=out_root, |
| 136 | spec_path=spec_path, |
| 137 | null_stats_dir=null_stats_dir, |
| 138 | manifest=manifest, |
| 139 | ) |