diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index c66772d..5213ef2 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -17,6 +17,7 @@ The repository now provides: - an on-demand source/build entry point that never installs the Windows toolchain; - a non-executing verifier for externally produced Windows artifacts; + it requires an exact repository-to-manifest-to-ZIP addon file set; - 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 04477c4..d63b4f1 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -53,8 +53,10 @@ python scripts/verify_rayforge_windows_checkpoint.py ` The verifier requires checkpoint schema 2 and portable artifact names, checks sizes and SHA-256 digests, rejects unsafe ZIP paths, confirms the pinned -Rayforge commit, compares every packaged addon file with its recorded digest, -and requires the pinned Rayforge license both inside the ZIP and as a sidecar. +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. It never executes the application or installer, and a passing result retains the manual GUI interaction gate. The installer, manifest and license sidecar form one distribution set. @@ -99,7 +101,8 @@ generated manual report to the kit manifest, repository commit and copied artifact-verification report by SHA-256. Editing, replacing or referring to an external provenance report therefore fails validation. The copied report must also name the pinned Rayforge commit, contain valid bundle and installer -SHA-256 values, and confirm that at least one packaged addon file was verified. +SHA-256 values, and confirm that the exact current addon file count was +verified. 1. Pass the non-executing artifact verifier above. 2. Launch the portable build with a fresh application-data directory. diff --git a/docs/roadmap.md b/docs/roadmap.md index de3e94c..4c53d97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -324,7 +324,9 @@ 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. +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. The manual checklist now also has a versioned, fail-closed report template and validator. Missing steps remain incomplete, stop conditions fail the run, and hashed screenshots stay outside the public repository pending sanitization. diff --git a/scripts/prepare_m1_acceptance_kit.py b/scripts/prepare_m1_acceptance_kit.py index dd3895e..96cd7db 100644 --- a/scripts/prepare_m1_acceptance_kit.py +++ b/scripts/prepare_m1_acceptance_kit.py @@ -16,6 +16,10 @@ RAYFORGE_COMMIT = json.loads( (ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8") )["commit"] SHA256 = re.compile(r"^[0-9a-f]{64}$") +EXPECTED_ADDON_FILES = sum( + path.is_file() and "__pycache__" not in path.parts + for path in (ROOT / "spikes" / "rayforge-jcz-no-device-addon").rglob("*") +) REPORT_TEMPLATE = "tests/fixtures/m1-manual-acceptance-template.json" KIT_FILES = { "inputs/synthetic-mopa-import.lbrn2": "tests/fixtures/synthetic-mopa-import.lbrn2", @@ -60,8 +64,7 @@ def create_kit( or verification.get("rayforge_commit") != RAYFORGE_COMMIT or SHA256.fullmatch(verification.get("bundle_sha256", "")) is None or SHA256.fullmatch(verification.get("installer_sha256", "")) is None - or not isinstance(verification.get("verified_addon_files"), int) - or verification.get("verified_addon_files", 0) <= 0 + or verification.get("verified_addon_files") != EXPECTED_ADDON_FILES ): raise ValueError("artifact-verification report did not pass the no-device gate") diff --git a/scripts/validate_m1_manual_acceptance.py b/scripts/validate_m1_manual_acceptance.py index 1b1767b..1aaafce 100644 --- a/scripts/validate_m1_manual_acceptance.py +++ b/scripts/validate_m1_manual_acceptance.py @@ -36,6 +36,10 @@ STOP_CONDITIONS = ( STEP_STATUSES = {"passed", "failed", "blocked", "not_run"} SHA256 = re.compile(r"^[0-9a-f]{64}$") GIT_COMMIT = re.compile(r"^[0-9a-f]{40}$") +EXPECTED_ADDON_FILES = sum( + path.is_file() and "__pycache__" not in path.parts + for path in (ROOT / "spikes" / "rayforge-jcz-no-device-addon").rglob("*") +) def _validate_evidence(report_root: Path, records: list[dict]) -> list[str]: @@ -120,11 +124,8 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]: errors.append("artifact verification has an invalid bundle SHA-256") if SHA256.fullmatch(verification.get("installer_sha256", "")) is None: errors.append("artifact verification has an invalid installer SHA-256") - if ( - not isinstance(verification.get("verified_addon_files"), int) - or verification.get("verified_addon_files", 0) <= 0 - ): - errors.append("artifact verification did not verify addon files") + if verification.get("verified_addon_files") != EXPECTED_ADDON_FILES: + errors.append("artifact verification did not verify the exact addon file set") return errors diff --git a/scripts/verify_rayforge_windows_checkpoint.py b/scripts/verify_rayforge_windows_checkpoint.py index 8756829..05c9765 100644 --- a/scripts/verify_rayforge_windows_checkpoint.py +++ b/scripts/verify_rayforge_windows_checkpoint.py @@ -11,6 +11,7 @@ from pathlib import Path, PurePosixPath ROOT = Path(__file__).resolve().parents[1] +ADDON_SOURCE = ROOT / "spikes" / "rayforge-jcz-no-device-addon" UPSTREAM = json.loads( (ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8") ) @@ -44,6 +45,24 @@ def _check_file(root: Path, record: dict, label: str) -> Path: return path +def _expected_addon_files() -> dict[PurePosixPath, str]: + return { + PurePosixPath(path.relative_to(ADDON_SOURCE).as_posix()): _digest( + path.read_bytes() + ) + for path in sorted(ADDON_SOURCE.rglob("*")) + if path.is_file() and "__pycache__" not in path.parts + } + + +def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath | None: + root_parts = root.parts + for index in range(len(path.parts) - len(root_parts)): + if path.parts[index : index + len(root_parts)] == root_parts: + return PurePosixPath(*path.parts[index + len(root_parts) :]) + return None + + def verify(manifest_path: Path) -> dict: manifest_path = manifest_path.resolve() manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) @@ -69,6 +88,23 @@ def verify(manifest_path: Path) -> dict: packaged_root = PurePosixPath(manifest["addon_packaged_root"]) if packaged_root.is_absolute() or ".." in packaged_root.parts: raise ValueError("unsafe packaged addon root") + expected_addon = _expected_addon_files() + manifest_addon = {} + for record in manifest.get("addon_source_files", []): + relative = PurePosixPath(record.get("path", "")) + if ( + not relative.parts + or relative.is_absolute() + or ".." in relative.parts + or relative in manifest_addon + ): + raise ValueError(f"unsafe or duplicate addon source path: {relative}") + manifest_addon[relative] = record.get("sha256", "") + if set(manifest_addon) != set(expected_addon): + raise ValueError("checkpoint addon file list does not match the repository") + for relative, digest in manifest_addon.items(): + if digest != expected_addon[relative]: + raise ValueError(f"checkpoint addon source differs from repository: {relative}") with zipfile.ZipFile(bundle) as archive: files = { @@ -92,24 +128,19 @@ def verify(manifest_path: Path) -> dict: if _text_digest_lf(archive.read(files[license_matches[0]])) != expected_license_hash: raise ValueError("bundled Rayforge license does not match the pinned upstream") - 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: + packaged_addon = {} + for path in files: + relative = _relative_below(path, packaged_root) + if relative is not None: + if relative in packaged_addon: + raise ValueError(f"packaged addon file is ambiguous: {relative}") + packaged_addon[relative] = path + if set(packaged_addon) != set(expected_addon): + raise ValueError("packaged addon file set does not match the repository") + for relative, digest in expected_addon.items(): + if _digest(archive.read(files[packaged_addon[relative]])) != digest: raise ValueError(f"packaged addon SHA-256 mismatch: {relative}") - verified_addon_files += 1 + verified_addon_files = len(packaged_addon) return { "status": "passed", diff --git a/tests/test_m1_acceptance_kit.py b/tests/test_m1_acceptance_kit.py index 65fdddf..c0168a5 100644 --- a/tests/test_m1_acceptance_kit.py +++ b/tests/test_m1_acceptance_kit.py @@ -6,6 +6,7 @@ from pathlib import Path from scripts.prepare_m1_acceptance_kit import ( KIT_FILES, + EXPECTED_ADDON_FILES, RAYFORGE_COMMIT, REPORT_TEMPLATE, ROOT, @@ -24,7 +25,7 @@ class M1AcceptanceKitTests(unittest.TestCase): "rayforge_commit": RAYFORGE_COMMIT, "bundle_sha256": "1" * 64, "installer_sha256": "2" * 64, - "verified_addon_files": 7, + "verified_addon_files": EXPECTED_ADDON_FILES, } def test_kit_contains_only_allowlisted_public_inputs(self): diff --git a/tests/test_m1_manual_acceptance.py b/tests/test_m1_manual_acceptance.py index 48f639e..e520e2c 100644 --- a/tests/test_m1_manual_acceptance.py +++ b/tests/test_m1_manual_acceptance.py @@ -6,6 +6,7 @@ import unittest from pathlib import Path from scripts.validate_m1_manual_acceptance import ( + EXPECTED_ADDON_FILES, RAYFORGE_COMMIT, ROOT, STEP_IDS, @@ -49,7 +50,7 @@ class M1ManualAcceptanceTests(unittest.TestCase): "rayforge_commit": RAYFORGE_COMMIT, "bundle_sha256": "1" * 64, "installer_sha256": "2" * 64, - "verified_addon_files": 7, + "verified_addon_files": EXPECTED_ADDON_FILES, } verification_path = root / "artifact-verification.json" verification_path.write_text(json.dumps(verification), encoding="utf-8") diff --git a/tests/test_windows_checkpoint_verifier.py b/tests/test_windows_checkpoint_verifier.py index c0c5e93..2cd741c 100644 --- a/tests/test_windows_checkpoint_verifier.py +++ b/tests/test_windows_checkpoint_verifier.py @@ -5,19 +5,30 @@ import unittest import zipfile from pathlib import Path -from scripts.verify_rayforge_windows_checkpoint import ROOT, UPSTREAM, verify +from scripts.verify_rayforge_windows_checkpoint import ( + ADDON_SOURCE, + 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: + def _checkpoint( + self, + root: Path, + unsafe_member: str | None = None, + extra_addon_file: 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", + path.relative_to(ADDON_SOURCE).as_posix(): path.read_bytes() + for path in sorted(ADDON_SOURCE.rglob("*")) + if path.is_file() and "__pycache__" not in path.parts } upstream = json.loads( (ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8") @@ -29,6 +40,11 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): 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 extra_addon_file: + archive.writestr( + f"{bundle_name[:-4]}/_internal/{ADDON_ROOT}/{extra_addon_file}", + b"unexpected", + ) archive.writestr( f"{bundle_name[:-4]}/THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt", license_data, @@ -79,7 +95,11 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): result = verify(self._checkpoint(Path(directory))) self.assertEqual(result["status"], "passed") self.assertFalse(result["executed_binaries"]) - self.assertEqual(result["verified_addon_files"], 2) + expected_count = sum( + path.is_file() and "__pycache__" not in path.parts + for path in ADDON_SOURCE.rglob("*") + ) + self.assertEqual(result["verified_addon_files"], expected_count) def test_modified_installer_is_rejected(self): with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: @@ -112,6 +132,30 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): with self.assertRaises(FileNotFoundError): verify(manifest) + def test_manifest_cannot_omit_or_change_repository_addon_source(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint(Path(directory)) + data = json.loads(manifest.read_text(encoding="utf-8")) + data["addon_source_files"].pop() + manifest.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "file list does not match"): + verify(manifest) + + manifest = self._checkpoint(Path(directory)) + data = json.loads(manifest.read_text(encoding="utf-8")) + data["addon_source_files"][0]["sha256"] = "0" * 64 + manifest.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "differs from repository"): + verify(manifest) + + def test_unlisted_packaged_addon_file_is_rejected(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + manifest = self._checkpoint( + Path(directory), extra_addon_file="pirate_ray_jcz/transport.py" + ) + with self.assertRaisesRegex(ValueError, "file set does not match"): + verify(manifest) + if __name__ == "__main__": unittest.main()