diff --git a/tests/test_release_build.py b/tests/test_release_build.py index 55ce5e7..f9676bc 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -37,7 +37,7 @@ def test_build_is_deterministic_and_clean_installable(self) -> None: manifest["artifact"]["sha256"], hashlib.sha256((first / name).read_bytes()).hexdigest() ) installed = subprocess.run( - ["python3", str(INSTALLER), str(first / name), "--skip-tests"], + ["python3", str(INSTALLER), str(first / name)], cwd=ROOT, capture_output=True, text=True, diff --git a/tools/verify_clean_install.py b/tools/verify_clean_install.py index aaa5633..79f8714 100644 --- a/tools/verify_clean_install.py +++ b/tools/verify_clean_install.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import os +import shutil import stat import subprocess import sys @@ -28,6 +30,26 @@ def run(*arguments: str, cwd: Path) -> None: raise ValueError((result.stdout + result.stderr).strip() or f"command failed: {arguments}") +def extract_with_modes(archive: zipfile.ZipFile, destination: Path) -> None: + """Extract validated members without following links and restore portable modes.""" + + for item in archive.infolist(): + relative = PurePosixPath(item.filename) + target = destination.joinpath(*relative.parts) + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + mode = stat.S_IMODE(item.external_attr >> 16) + if mode not in {0o644, 0o755}: + raise ValueError(f"release ZIP has an unsupported file mode: {item.filename}") + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(item) as source, target.open("wb") as output: + shutil.copyfileobj(source, output) + os.chmod(target, mode) + if stat.S_IMODE(target.stat().st_mode) != mode: + raise ValueError(f"could not restore release file mode: {item.filename}") + + def main() -> int: args = parse_args() try: @@ -50,7 +72,7 @@ def main() -> int: raise ValueError("release ZIP contains a symbolic link") with tempfile.TemporaryDirectory(prefix="web-plan-execute-install-") as temporary: destination = Path(temporary) - archive.extractall(destination) + extract_with_modes(archive, destination) skill = destination / "web-plan-execute" run( sys.executable,