From 7131b50e2e6b973f84550accd139b904091b0d18 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 21:45:37 +0200 Subject: [PATCH] build: reject ambiguous checkpoint archives --- docs/CODEX_HANDOFF.md | 3 +- docs/research/m1-no-device-acceptance.md | 4 +- docs/roadmap.md | 2 +- scripts/verify_rayforge_windows_checkpoint.py | 39 +++++++++++--- tests/test_windows_checkpoint_verifier.py | 54 ++++++++++++++++++- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 4a93f6b..2f8a576 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -18,7 +18,8 @@ The repository now provides: toolchain, rejects modified upstream source and replaces only the validated addon staging directory; - a non-executing verifier for externally produced Windows artifacts; - it requires an exact repository-to-manifest-to-ZIP addon file set; + it requires an exact repository-to-manifest-to-ZIP addon file set and + rejects unsafe, colliding, encrypted or linked archive entries; - a checksummed acceptance-kit generator containing only allowlisted public synthetic inputs, with provenance binding to the repository commit and non-executing artifact-verification report, including the Rayforge pin and diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index 1bf435f..4c8d955 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -60,7 +60,9 @@ sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned Rayforge commit, requires the manifest and bundle to contain exactly the versioned repository addon file set with matching digests, and requires the pinned Rayforge license both inside the ZIP and as a sidecar. Missing, changed -or additional addon files fail verification. +or additional addon files fail verification. Archive entries also fail on +duplicate or case-colliding Windows paths, backslashes, drive-like paths, +encryption or symbolic links. The schema-1 verification output records the exact checkpoint-manifest name and SHA-256 plus the fixed checkpoint and addon identities, so later manual acceptance can retain the complete artifact provenance chain. diff --git a/docs/roadmap.md b/docs/roadmap.md index 74d3c6a..ef65b24 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -328,7 +328,7 @@ An artifact produced elsewhere can be checked locally without execution: the verifier rejects unsafe paths or changed bytes and confirms the pin plus every packaged addon source before the manual GUI checklist may begin. It also rejects omitted manifest entries and extra files hidden in the packaged addon -directory. +directory, plus ambiguous or unsafe Windows ZIP entries. The manual checklist now also has a versioned, fail-closed report template and validator. Missing steps remain incomplete, a complete operator run remains ready for review until a named, timezone-stamped human review is recorded, diff --git a/scripts/verify_rayforge_windows_checkpoint.py b/scripts/verify_rayforge_windows_checkpoint.py index 7d250e0..2b8297f 100644 --- a/scripts/verify_rayforge_windows_checkpoint.py +++ b/scripts/verify_rayforge_windows_checkpoint.py @@ -6,6 +6,7 @@ import argparse import hashlib import json import re +import stat import zipfile from pathlib import Path, PurePosixPath @@ -63,6 +64,35 @@ def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath | return None +def _safe_archive_files(archive: zipfile.ZipFile) -> dict[PurePosixPath, zipfile.ZipInfo]: + files = {} + seen = set() + for info in archive.infolist(): + name = info.filename + path = PurePosixPath(name) + canonical = name.rstrip("/").casefold() + if ( + not name + or "\\" in name + or "\x00" in name + or path.is_absolute() + or ".." in path.parts + or (path.parts and ":" in path.parts[0]) + ): + raise ValueError("bundle contains an unsafe path") + if not canonical or canonical in seen: + raise ValueError("bundle contains a duplicate or case-colliding path") + seen.add(canonical) + if info.flag_bits & 0x1: + raise ValueError("bundle contains an encrypted entry") + mode = (info.external_attr >> 16) & 0xFFFF + if stat.S_ISLNK(mode): + raise ValueError("bundle contains a symbolic link") + if not info.is_dir(): + files[path] = info + return files + + def verify(manifest_path: Path) -> dict: manifest_path = manifest_path.resolve() manifest_bytes = manifest_path.read_bytes() @@ -112,14 +142,7 @@ def verify(manifest_path: Path) -> dict: raise ValueError(f"checkpoint addon source differs from repository: {relative}") with zipfile.ZipFile(bundle) as archive: - files = { - PurePosixPath(info.filename): info - for info in archive.infolist() - if not info.is_dir() - } - for path in files: - if path.is_absolute() or ".." in path.parts: - raise ValueError("bundle contains an unsafe path") + files = _safe_archive_files(archive) executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe" if not any(path.name == executable for path in files): raise ValueError("bundle application executable is missing") diff --git a/tests/test_windows_checkpoint_verifier.py b/tests/test_windows_checkpoint_verifier.py index d95137a..9d16948 100644 --- a/tests/test_windows_checkpoint_verifier.py +++ b/tests/test_windows_checkpoint_verifier.py @@ -1,14 +1,18 @@ import hashlib import json +import stat import tempfile import unittest +import warnings import zipfile from pathlib import Path +from types import SimpleNamespace from scripts.verify_rayforge_windows_checkpoint import ( ADDON_SOURCE, ROOT, UPSTREAM, + _safe_archive_files, verify, ) @@ -22,6 +26,9 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): root: Path, unsafe_member: str | None = None, extra_addon_file: str | None = None, + duplicate_member: bool = False, + case_collision: bool = False, + symlink_member: bool = False, ) -> Path: bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip" executable = bundle_name.removesuffix(".zip") + ".exe" @@ -50,7 +57,26 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): license_data, ) if unsafe_member: - archive.writestr(unsafe_member, b"unsafe") + unsafe = zipfile.ZipInfo("placeholder") + unsafe.filename = unsafe_member + unsafe.orig_filename = unsafe_member + archive.writestr(unsafe, b"unsafe") + if duplicate_member: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + archive.writestr( + f"{bundle_name[:-4]}/{executable}", b"duplicate" + ) + if case_collision: + archive.writestr( + f"{bundle_name[:-4].upper()}/{executable.upper()}", + b"case collision", + ) + if symlink_member: + link = zipfile.ZipInfo(f"{bundle_name[:-4]}/link") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(link, "target") installer = root / f"{bundle_name[:-4]}-installer.exe" installer.write_bytes(b"synthetic installer, never executed") license_sidecar = root / "Rayforge-LICENSE.txt" @@ -118,6 +144,32 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "unsafe path"): verify(manifest) + def test_windows_unsafe_and_colliding_archive_paths_are_rejected(self): + unsafe = SimpleNamespace( + filename="folder\\escape.txt", + flag_bits=0, + external_attr=0, + is_dir=lambda: False, + ) + archive = SimpleNamespace(infolist=lambda: [unsafe]) + with self.assertRaisesRegex(ValueError, "unsafe path"): + _safe_archive_files(archive) + + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory), duplicate_member=True) + with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"): + verify(manifest) + + manifest = self._checkpoint(Path(directory), case_collision=True) + with self.assertRaisesRegex(ValueError, "duplicate or case-colliding"): + verify(manifest) + + def test_symbolic_link_archive_entry_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory), symlink_member=True) + with self.assertRaisesRegex(ValueError, "symbolic link"): + verify(manifest) + def test_manifest_cannot_reference_external_artifacts(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: manifest = self._checkpoint(Path(directory))