diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index 996b8f1..8244f64 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -80,6 +80,18 @@ blocks encoding. Use only the isolated no-device profile and an empty work area. Do not connect or configure a controller. +Prepare a fresh, checksummed kit containing only the public synthetic inputs +and a new report template: + +```powershell +python scripts/prepare_m1_acceptance_kit.py +``` + +The default output is `.build/acceptance-kit/`. The generator uses a fixed +public allowlist, records the repository commit and refuses to overwrite a +non-empty directory. It does not include the application bundle, private +LightBurn evidence or any machine data. + 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. @@ -97,15 +109,14 @@ or configure a controller. Any device discovery prompt, physical-device status, transport attempt or unlabelled framing action is a stop condition and fails the checkpoint. -Record the run by copying -`tests/fixtures/m1-manual-acceptance-template.json` outside the repository, -filling it in and placing sanitized evidence below the report directory. Then -validate it without launching the application: +Record the run in the generated `manual-report.json` and place sanitized +evidence below the report directory. Then validate it without launching the +application: ```powershell python scripts/validate_m1_manual_acceptance.py ` - .build/acceptance/manual-report.json ` - --output .build/acceptance/manual-validation.json + .build/acceptance-kit/manual-report.json ` + --output .build/acceptance-kit/manual-validation.json ``` The validator verifies evidence hashes and relative paths. It reports diff --git a/docs/roadmap.md b/docs/roadmap.md index 1747f7d..466860e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -327,6 +327,9 @@ packaged addon source before the manual GUI checklist may begin. 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. +A reproducible kit generator copies only allowlisted synthetic inputs into the +ignored build area, records their hashes and refuses to overwrite an existing +test run. ## Decision rules diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index f5d5382..577d2ca 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -75,13 +75,16 @@ 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 79 public +- one consolidated no-device acceptance report that reruns all 83 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; - a versioned manual desktop-acceptance template and validator that cannot - pass missing steps, connected hardware, changed evidence or stop conditions. + pass missing steps, connected hardware, changed evidence or stop conditions; +- a deterministic acceptance-kit generator that copies only four allowlisted + public synthetic inputs, hashes them and refuses to overwrite an existing + run directory. The remaining M1 gates are deliberately explicit: diff --git a/scripts/prepare_m1_acceptance_kit.py b/scripts/prepare_m1_acceptance_kit.py new file mode 100644 index 0000000..554e8d6 --- /dev/null +++ b/scripts/prepare_m1_acceptance_kit.py @@ -0,0 +1,92 @@ +"""Create a checksummed M1 desktop acceptance kit from public fixtures only.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +KIT_FILES = { + "manual-report.json": "tests/fixtures/m1-manual-acceptance-template.json", + "inputs/synthetic-mopa-import.lbrn2": "tests/fixtures/synthetic-mopa-import.lbrn2", + "inputs/synthetic-jcz-device.lbdev": "tests/fixtures/synthetic-jcz-device.lbdev", + "inputs/synthetic-pirateray-recipe-metadata.json": ( + "tests/fixtures/synthetic-pirateray-recipe-metadata.json" + ), +} + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _repository_commit() -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def create_kit(output: Path, repository_commit: str | None = None) -> dict: + output = output.resolve() + if output.exists() and any(output.iterdir()): + raise ValueError(f"acceptance-kit directory is not empty: {output}") + output.mkdir(parents=True, exist_ok=True) + + records = [] + for destination, source_name in KIT_FILES.items(): + source = ROOT / source_name + target = output / destination + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + records.append({ + "path": destination, + "source": source_name, + "classification": "synthetic-public", + "bytes": target.stat().st_size, + "sha256": _sha256(target), + }) + + manifest = { + "schema_version": 1, + "kit": "M1 packaged no-device desktop acceptance", + "repository_commit": repository_commit or _repository_commit(), + "hardware_access_authorized": False, + "contains_private_evidence": False, + "files": records, + } + manifest_path = output / "kit-manifest.json" + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + newline="\n", + ) + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output", + type=Path, + default=ROOT / ".build" / "acceptance-kit", + ) + args = parser.parse_args() + result = create_kit(args.output) + print(f"kit={args.output.resolve()}") + print(f"files={len(result['files'])}") + print(f"repository_commit={result['repository_commit']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_m1_acceptance_kit.py b/tests/test_m1_acceptance_kit.py new file mode 100644 index 0000000..68ca3ba --- /dev/null +++ b/tests/test_m1_acceptance_kit.py @@ -0,0 +1,55 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.prepare_m1_acceptance_kit import KIT_FILES, ROOT, create_kit + + +class M1AcceptanceKitTests(unittest.TestCase): + def test_kit_contains_only_allowlisted_public_inputs(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + output = Path(directory) / "kit" + manifest = create_kit(output, repository_commit="a" * 40) + self.assertFalse(manifest["hardware_access_authorized"]) + self.assertFalse(manifest["contains_private_evidence"]) + self.assertEqual( + {record["path"] for record in manifest["files"]}, + set(KIT_FILES), + ) + for record in manifest["files"]: + path = output / record["path"] + self.assertEqual(record["bytes"], path.stat().st_size) + self.assertEqual(record["sha256"], hashlib.sha256(path.read_bytes()).hexdigest()) + self.assertEqual(record["classification"], "synthetic-public") + + def test_manifest_is_reproducible_for_same_commit(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + create_kit(root / "one", repository_commit="b" * 40) + create_kit(root / "two", repository_commit="b" * 40) + one = (root / "one" / "kit-manifest.json").read_bytes() + two = (root / "two" / "kit-manifest.json").read_bytes() + self.assertEqual(one, two) + + def test_nonempty_output_is_not_overwritten(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + output = Path(directory) / "kit" + output.mkdir() + sentinel = output / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "not empty"): + create_kit(output, repository_commit="c" * 40) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + + def test_manifest_on_disk_matches_returned_data(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + output = Path(directory) / "kit" + expected = create_kit(output, repository_commit="d" * 40) + actual = json.loads((output / "kit-manifest.json").read_text(encoding="utf-8")) + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main()