"""swaypack tarball → ready-to-run directory tree (Sprint 26 / X3).
Inverse of :mod:`dlm_sway.cli._pack`. Given a ``*.swaypack.tar.gz``,
extract its contents into a target dir, validate the manifest, and
return enough info for ``sway unpack`` to print actionable next-step
instructions ("``cd
&& SWAY_NULL_CACHE_DIR=/null-stats sway run sway.yaml``").
Path-traversal hardening: ``tarfile.extractall`` defaults to ``filter='data'``
on Python 3.12+, which already rejects absolute paths and ``../``
escape attempts. We pin that filter explicitly so the safety holds
on 3.11 (which we support per ``pyproject.toml``) and isn't a
rolling-mean-of-warnings trap on future versions.
"""
from __future__ import annotations
import json
import tarfile
from dataclasses import dataclass
from pathlib import Path
from dlm_sway.core.errors import SwayError
class UnpackError(SwayError):
"""Raised when ``sway unpack`` can't extract a usable swaypack."""
@dataclass(frozen=True, slots=True)
class UnpackReport:
"""Result of a successful unpack call.
Attributes
----------
out_dir:
Where the contents were extracted (``/swaypack/``).
spec_path:
Convenience pointer to ``out_dir / "sway.yaml"``.
null_stats_dir:
Path to the unpacked null-stats cache, or ``None`` when the
pack didn't bundle one. Callers set ``SWAY_NULL_CACHE_DIR``
to this value before running ``sway run`` to honor the
bundled stats.
manifest:
Parsed ``manifest.json`` — useful for diagnostics.
"""
out_dir: Path
spec_path: Path
null_stats_dir: Path | None
manifest: dict[str, object]
def unpack_swaypack(pack_path: Path, *, target_dir: Path) -> UnpackReport:
"""Extract a swaypack into ``target_dir``.
Parameters
----------
pack_path:
Path to a ``.swaypack.tar.gz`` produced by :func:`pack_spec`.
target_dir:
Parent directory to extract into. The pack's contents land
at ``target_dir / "swaypack/"`` (the tarball's top-level
directory). Must not already contain a ``swaypack/`` dir.
Returns
-------
UnpackReport
Pointers callers need to wire into a ``sway run`` invocation.
Raises
------
UnpackError
Pack file missing / unreadable, target dir already has a
``swaypack/`` subdir, manifest missing or version-incompatible.
"""
pack_path = Path(pack_path).expanduser().resolve()
target_dir = Path(target_dir).expanduser().resolve()
if not pack_path.exists():
raise UnpackError(f"swaypack not found: {pack_path}")
if not pack_path.is_file():
raise UnpackError(f"swaypack must be a file, got: {pack_path}")
out_root = target_dir / "swaypack"
if out_root.exists():
raise UnpackError(
f"refusing to overwrite existing {out_root} — delete it or "
f"pass --out to a fresh directory"
)
target_dir.mkdir(parents=True, exist_ok=True)
try:
with tarfile.open(str(pack_path), mode="r:gz") as tar:
# ``filter='data'`` rejects absolute paths, ``../`` escapes,
# and special-device entries. Required for safe extraction
# of untrusted-source archives — packs may travel via
# email / shared drives.
tar.extractall(path=str(target_dir), filter="data")
except (tarfile.ReadError, tarfile.ExtractError, OSError) as exc:
raise UnpackError(f"failed to extract {pack_path}: {type(exc).__name__}: {exc}") from exc
if not out_root.exists():
raise UnpackError(
f"swaypack extracted but missing the expected 'swaypack/' "
f"directory; either {pack_path} isn't a sway pack or it "
f"was built by an incompatible writer"
)
manifest_path = out_root / "manifest.json"
if not manifest_path.exists():
raise UnpackError(f"swaypack missing manifest.json (looked at {manifest_path})")
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise UnpackError(f"manifest.json is not valid JSON: {exc}") from exc
swaypack_version = manifest.get("swaypack_version")
if swaypack_version != 1:
raise UnpackError(
f"unsupported swaypack_version={swaypack_version}. This sway "
f"build understands swaypack_version=1 (S26)."
)
spec_path = out_root / manifest.get("spec_filename", "sway.yaml")
if not spec_path.exists():
raise UnpackError(
f"manifest claims spec at {spec_path.name} but it's missing from the pack"
)
candidate_null_dir = out_root / "null-stats"
null_stats_dir: Path | None = candidate_null_dir if candidate_null_dir.is_dir() else None
return UnpackReport(
out_dir=out_root,
spec_path=spec_path,
null_stats_dir=null_stats_dir,
manifest=manifest,
)