Python · 4982 bytes Raw Blame History
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 )