From 28de151e6504c7f9e7b693890298c27ccc1a13b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 08:33:50 +0200 Subject: [PATCH] build: verify portable Windows checkpoints --- README.md | 4 +- docs/research/m1-no-device-acceptance.md | 17 ++- docs/roadmap.md | 3 + docs/status/phase-0.md | 7 +- scripts/build_rayforge_windows_checkpoint.ps1 | 6 +- scripts/prepare_rayforge_build.ps1 | 2 +- scripts/verify_rayforge_windows_checkpoint.py | 121 ++++++++++++++++++ tests/test_m1_acceptance_pipeline.py | 8 ++ tests/test_windows_checkpoint_verifier.py | 91 +++++++++++++ 9 files changed, 251 insertions(+), 8 deletions(-) create mode 100644 scripts/verify_rayforge_windows_checkpoint.py create mode 100644 tests/test_windows_checkpoint_verifier.py diff --git a/README.md b/README.md index 8757ab2..ac3cd6b 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,8 @@ likely data leaks. They do not certify hardware safety. Rayforge source and its Windows build stack are not vendored or downloaded by these checks. The exact upstream revision is recorded in `upstreams/rayforge.json`; run `scripts/prepare_rayforge_build.ps1` explicitly -to populate the ignored build cache. Dependency installation and bundle -creation require separate command switches. +to populate the ignored build cache. The repository does not install the +Windows toolchain; bundle creation requires an already reviewed runtime. ## Safety boundary diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index 076ead2..fb31d8a 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -42,6 +42,21 @@ Before staging, the script rejects transport imports in the addon. The build manifest records the exact upstream commit and hashes every addon source file, the portable archive and installer. It does not claim the GUI has been tested. +Copy the ZIP, installer and JSON manifest together, then verify an externally +produced checkpoint without launching either binary: + +```powershell +python scripts/verify_rayforge_windows_checkpoint.py ` + .build/artifacts/rayforge-windows-checkpoint.json ` + --output .build/artifacts/rayforge-windows-verification.json +``` + +The verifier requires portable artifact names, checks sizes and SHA-256 +digests, rejects unsafe ZIP paths, confirms the pinned Rayforge commit and +compares every packaged addon file with its recorded digest. It never executes +the application or installer, and a passing result retains the manual GUI +interaction gate. + ## Automated acceptance Run with the isolated Rayforge Python environment: @@ -65,7 +80,7 @@ blocks encoding. Use only the isolated no-device profile and an empty work area. Do not connect or configure a controller. -1. Verify the artifact and addon hashes against the build manifest. +1. Pass the non-executing artifact verifier above. 2. Launch the portable build with a fresh application-data directory. 3. Confirm the UI identifies the state as offline/no-device. 4. Open synthetic geometry and inspect the 110 × 110 mm synthetic field. diff --git a/docs/roadmap.md b/docs/roadmap.md index b5297e1..5fd18f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -321,6 +321,9 @@ The transport-free portions are now consolidated by the It reruns the public suite and leak guard and hashes the validated regression reports. The on-demand build entry point fetches pinned source and stages the addon but deliberately refuses to install a missing Windows toolchain. +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. ## Decision rules diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 6e97624..e88436c 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -75,8 +75,11 @@ evidence for: - independent layer visibility/output state and ordered serialization; - material-recipe matching plus a fail-closed PirateRay provenance and exact machine/source/lens/correction compatibility gate; -- one consolidated no-device acceptance report that reruns all 69 public - tests and the leak guard, then hashes the regression evidence. +- one consolidated no-device acceptance report that reruns all 74 public + tests and the leak guard, then hashes the regression evidence; +- a non-executing Windows artifact verifier that checks portable paths, + archive safety, sizes, hashes, the pinned Rayforge revision and every + packaged addon source while retaining the manual GUI gate. The remaining M1 gates are deliberately explicit: diff --git a/scripts/build_rayforge_windows_checkpoint.ps1 b/scripts/build_rayforge_windows_checkpoint.ps1 index 6c19671..595f53b 100644 --- a/scripts/build_rayforge_windows_checkpoint.ps1 +++ b/scripts/build_rayforge_windows_checkpoint.ps1 @@ -88,19 +88,21 @@ $addonFiles = Get-ChildItem -LiteralPath $addonSource -Recurse -File | } } $result = [ordered]@{ + schema_version = 1 checkpoint = "Rayforge Windows no-device bundle" rayforge_commit = $verifiedCommit addon = "the_pirate_ray_jcz" + addon_packaged_root = "rayforge/builtin_addons/rayforge-addon-the-pirate-ray-jcz" addon_source_files = $addonFiles hardware_access = $false transport = $null bundle = [ordered]@{ - path = $archive + path = Split-Path -Leaf $archive sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $archive).Length } installer = [ordered]@{ - path = $installer + path = Split-Path -Leaf $installer sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $installer).Length } diff --git a/scripts/prepare_rayforge_build.ps1 b/scripts/prepare_rayforge_build.ps1 index fd0a42c..2c6c7da 100644 --- a/scripts/prepare_rayforge_build.ps1 +++ b/scripts/prepare_rayforge_build.ps1 @@ -64,7 +64,7 @@ if (-not $BuildWindowsBundle) { $bash = "C:\msys64\usr\bin\bash.exe" if (-not (Test-Path -LiteralPath $bash)) { - throw "MSYS2 is required for the upstream Windows build. Install it explicitly before using build flags." + throw "A reviewed MSYS2 Windows build runtime is not available. No dependencies were installed." } $msysCheckout = (& $bash -lc "cygpath -u '$checkout'").Trim() diff --git a/scripts/verify_rayforge_windows_checkpoint.py b/scripts/verify_rayforge_windows_checkpoint.py new file mode 100644 index 0000000..2724f29 --- /dev/null +++ b/scripts/verify_rayforge_windows_checkpoint.py @@ -0,0 +1,121 @@ +"""Verify a packaged no-device checkpoint without executing its binaries.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path, PurePosixPath + + +ROOT = Path(__file__).resolve().parents[1] +UPSTREAM = json.loads( + (ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8") +) +SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +def _digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _artifact_path(root: Path, value: str) -> Path: + relative = PurePosixPath(value) + if relative.is_absolute() or ".." in relative.parts or len(relative.parts) != 1: + raise ValueError(f"artifact path must be one filename: {value!r}") + return root / relative.name + + +def _check_file(root: Path, record: dict, label: str) -> Path: + path = _artifact_path(root, record["path"]) + data = path.read_bytes() + if record.get("bytes") != len(data): + raise ValueError(f"{label} size mismatch") + digest = record.get("sha256", "") + if not SHA256.fullmatch(digest) or digest != _digest(data): + raise ValueError(f"{label} SHA-256 mismatch") + return path + + +def verify(manifest_path: Path) -> dict: + manifest_path = manifest_path.resolve() + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + if manifest.get("schema_version") != 1: + raise ValueError("unsupported checkpoint schema") + if manifest.get("rayforge_commit") != UPSTREAM["commit"]: + raise ValueError("Rayforge commit does not match the repository pin") + if manifest.get("hardware_access") is not False or manifest.get("transport") is not None: + raise ValueError("checkpoint does not declare a no-device boundary") + if manifest.get("manual_interaction_required") is not True: + raise ValueError("checkpoint must retain the manual interaction gate") + + artifact_root = manifest_path.parent + bundle = _check_file(artifact_root, manifest["bundle"], "bundle") + installer = _check_file(artifact_root, manifest["installer"], "installer") + packaged_root = PurePosixPath(manifest["addon_packaged_root"]) + if packaged_root.is_absolute() or ".." in packaged_root.parts: + raise ValueError("unsafe packaged addon root") + + 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") + executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe" + if not any(path.name == executable for path in files): + raise ValueError("bundle application executable is missing") + + verified_addon_files = 0 + for record in manifest["addon_source_files"]: + relative = PurePosixPath(record["path"]) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError("unsafe addon source path") + matches = [ + path for path in files + if tuple(path.parts[-len((packaged_root / relative).parts):]) + == (packaged_root / relative).parts + ] + if len(matches) != 1: + raise ValueError(f"packaged addon file missing or ambiguous: {relative}") + digest = record.get("sha256", "") + if not SHA256.fullmatch(digest): + raise ValueError(f"invalid addon SHA-256: {relative}") + if _digest(archive.read(files[matches[0]])) != digest: + raise ValueError(f"packaged addon SHA-256 mismatch: {relative}") + verified_addon_files += 1 + + return { + "status": "passed", + "rayforge_commit": manifest["rayforge_commit"], + "hardware_access": False, + "executed_binaries": False, + "bundle_sha256": manifest["bundle"]["sha256"], + "installer_sha256": manifest["installer"]["sha256"], + "verified_addon_files": verified_addon_files, + "manual_interaction_required": True, + "installer_name": installer.name, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("manifest", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + result = verify(args.manifest) + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8", newline="\n") + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_m1_acceptance_pipeline.py b/tests/test_m1_acceptance_pipeline.py index 2ec1c70..be1b5fa 100644 --- a/tests/test_m1_acceptance_pipeline.py +++ b/tests/test_m1_acceptance_pipeline.py @@ -6,6 +6,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ACCEPTANCE = ROOT / "scripts" / "run_m1_no_device_acceptance.py" BUILD = ROOT / "scripts" / "build_rayforge_windows_checkpoint.ps1" +VERIFY = ROOT / "scripts" / "verify_rayforge_windows_checkpoint.py" UPSTREAM = ROOT / "upstreams" / "rayforge.json" @@ -54,6 +55,13 @@ class M1AcceptancePipelineTests(unittest.TestCase): for forbidden in ("import usb", "import serial", "import socket"): self.assertIn(forbidden, source) + def test_checkpoint_has_a_non_executing_artifact_verifier(self): + source = VERIFY.read_text(encoding="utf-8") + self.assertIn("zipfile.ZipFile", source) + self.assertIn("Rayforge commit does not match", source) + self.assertIn('"executed_binaries": False', source) + self.assertNotIn("subprocess", source) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_windows_checkpoint_verifier.py b/tests/test_windows_checkpoint_verifier.py new file mode 100644 index 0000000..1620d8f --- /dev/null +++ b/tests/test_windows_checkpoint_verifier.py @@ -0,0 +1,91 @@ +import hashlib +import json +import tempfile +import unittest +import zipfile +from pathlib import Path + +from scripts.verify_rayforge_windows_checkpoint import ROOT, UPSTREAM, verify + + +ADDON_ROOT = "rayforge/builtin_addons/rayforge-addon-the-pirate-ray-jcz" + + +class WindowsCheckpointVerifierTests(unittest.TestCase): + def _checkpoint(self, root: Path, unsafe_member: str | None = None) -> Path: + bundle_name = f"rayforge-vpirateray-{UPSTREAM['commit'][:12]}.zip" + executable = bundle_name.removesuffix(".zip") + ".exe" + addon_files = { + "rayforge-addon.yaml": b"name: the_pirate_ray_jcz\n", + "pirate_ray_jcz/worker.py": b"TRANSPORT = None\n", + } + bundle = root / bundle_name + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr(f"{bundle_name[:-4]}/{executable}", b"synthetic") + for relative, data in addon_files.items(): + archive.writestr(f"{bundle_name[:-4]}/_internal/{ADDON_ROOT}/{relative}", data) + if unsafe_member: + archive.writestr(unsafe_member, b"unsafe") + installer = root / f"{bundle_name[:-4]}-installer.exe" + installer.write_bytes(b"synthetic installer, never executed") + manifest = { + "schema_version": 1, + "checkpoint": "Rayforge Windows no-device bundle", + "rayforge_commit": UPSTREAM["commit"], + "addon": "the_pirate_ray_jcz", + "addon_packaged_root": ADDON_ROOT, + "addon_source_files": [ + {"path": name, "sha256": hashlib.sha256(data).hexdigest()} + for name, data in addon_files.items() + ], + "hardware_access": False, + "transport": None, + "bundle": { + "path": bundle.name, + "sha256": hashlib.sha256(bundle.read_bytes()).hexdigest(), + "bytes": bundle.stat().st_size, + }, + "installer": { + "path": installer.name, + "sha256": hashlib.sha256(installer.read_bytes()).hexdigest(), + "bytes": installer.stat().st_size, + }, + "manual_interaction_required": True, + } + path = root / "rayforge-windows-checkpoint.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + return path + + def test_valid_checkpoint_is_verified_without_execution(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + result = verify(self._checkpoint(Path(directory))) + self.assertEqual(result["status"], "passed") + self.assertFalse(result["executed_binaries"]) + self.assertEqual(result["verified_addon_files"], 2) + + def test_modified_installer_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory)) + data = json.loads(manifest.read_text(encoding="utf-8")) + (manifest.parent / data["installer"]["path"]).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "installer (size|SHA-256) mismatch"): + verify(manifest) + + def test_bundle_path_traversal_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory), "../escape.txt") + with self.assertRaisesRegex(ValueError, "unsafe path"): + verify(manifest) + + def test_manifest_cannot_reference_external_artifacts(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory)) + data = json.loads(manifest.read_text(encoding="utf-8")) + data["bundle"]["path"] = "../outside.zip" + manifest.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "one filename"): + verify(manifest) + + +if __name__ == "__main__": + unittest.main()