build: bind checkpoint manifest to acceptance
This commit is contained in:
parent
644c99a9ca
commit
3c26e1bdab
9 changed files with 191 additions and 22 deletions
|
|
@ -21,7 +21,7 @@ The repository now provides:
|
|||
- 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
|
||||
bundle, installer and addon-verification fields;
|
||||
bundle, installer, addon-verification and source checkpoint-manifest fields;
|
||||
- a fail-closed manual desktop-acceptance report and validator;
|
||||
- pinned upstream contract/license manifests and a drift checker;
|
||||
- a fail-closed API-23 adapter isolating Rayforge's internal driver registry;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ 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.
|
||||
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.
|
||||
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.
|
||||
|
|
@ -90,7 +93,8 @@ report into the kit at creation time:
|
|||
|
||||
```powershell
|
||||
python scripts/prepare_m1_acceptance_kit.py `
|
||||
--artifact-verification .build/artifacts/rayforge-windows-verification.json
|
||||
--artifact-verification .build/artifacts/rayforge-windows-verification.json `
|
||||
--checkpoint-manifest .build/artifacts/rayforge-windows-checkpoint.json
|
||||
```
|
||||
|
||||
The default output is `.build/acceptance-kit/`. The generator uses a fixed
|
||||
|
|
@ -102,7 +106,8 @@ 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 the exact current addon file count was
|
||||
verified.
|
||||
verified. Its own schema, checkpoint identity and addon identity are fixed, and
|
||||
its recorded checkpoint-manifest hash must match the copied schema-2 manifest.
|
||||
|
||||
1. Pass the non-executing artifact verifier above.
|
||||
2. Launch the portable build with a fresh application-data directory.
|
||||
|
|
|
|||
|
|
@ -335,7 +335,8 @@ ignored build area, records their hashes and refuses to overwrite an existing
|
|||
test run. Its schema-2 provenance chain binds the editable manual report to the
|
||||
exact kit manifest, repository commit and non-executing artifact-verification
|
||||
report; substituted or changed inputs, a different Rayforge pin, malformed
|
||||
artifact hashes or an unverified addon fail validation.
|
||||
artifact hashes, a changed checkpoint manifest or an unverified addon fail
|
||||
validation.
|
||||
The pinned upstream contracts and license notices are now machine-verifiable.
|
||||
Rayforge's documented addon lifecycle is viable, but production driver
|
||||
registration still needs a supported symmetric registry API. The current
|
||||
|
|
|
|||
|
|
@ -49,15 +49,28 @@ def create_kit(
|
|||
output: Path,
|
||||
repository_commit: str | None = None,
|
||||
artifact_verification: Path | None = None,
|
||||
checkpoint_manifest: Path | None = None,
|
||||
) -> dict:
|
||||
verification_source = None
|
||||
if artifact_verification is not None:
|
||||
checkpoint_source = None
|
||||
if (artifact_verification is None) != (checkpoint_manifest is None):
|
||||
raise ValueError(
|
||||
"artifact verification and checkpoint manifest must be supplied together"
|
||||
)
|
||||
if artifact_verification is not None and checkpoint_manifest is not None:
|
||||
verification_source = artifact_verification.resolve()
|
||||
checkpoint_source = checkpoint_manifest.resolve()
|
||||
verification = json.loads(
|
||||
verification_source.read_text(encoding="utf-8-sig")
|
||||
)
|
||||
checkpoint_bytes = checkpoint_source.read_bytes()
|
||||
checkpoint = json.loads(checkpoint_bytes.decode("utf-8-sig"))
|
||||
checkpoint_record = verification.get("checkpoint_manifest", {})
|
||||
if (
|
||||
verification.get("status") != "passed"
|
||||
verification.get("schema_version") != 1
|
||||
or verification.get("verification")
|
||||
!= "Rayforge Windows no-device artifact verification"
|
||||
or verification.get("status") != "passed"
|
||||
or verification.get("hardware_access") is not False
|
||||
or verification.get("executed_binaries") is not False
|
||||
or verification.get("manual_interaction_required") is not True
|
||||
|
|
@ -65,6 +78,19 @@ def create_kit(
|
|||
or SHA256.fullmatch(verification.get("bundle_sha256", "")) is None
|
||||
or SHA256.fullmatch(verification.get("installer_sha256", "")) is None
|
||||
or verification.get("verified_addon_files") != EXPECTED_ADDON_FILES
|
||||
or verification.get("addon") != "the_pirate_ray_jcz"
|
||||
or checkpoint_record.get("path") != checkpoint_source.name
|
||||
or checkpoint_record.get("sha256")
|
||||
!= hashlib.sha256(checkpoint_bytes).hexdigest()
|
||||
or checkpoint.get("schema_version") != 2
|
||||
or checkpoint.get("checkpoint") != "Rayforge Windows no-device bundle"
|
||||
or checkpoint.get("addon") != verification.get("addon")
|
||||
or checkpoint.get("rayforge_commit")
|
||||
!= verification.get("rayforge_commit")
|
||||
or checkpoint.get("bundle", {}).get("sha256")
|
||||
!= verification.get("bundle_sha256")
|
||||
or checkpoint.get("installer", {}).get("sha256")
|
||||
!= verification.get("installer_sha256")
|
||||
):
|
||||
raise ValueError("artifact-verification report did not pass the no-device gate")
|
||||
|
||||
|
|
@ -116,6 +142,8 @@ def create_kit(
|
|||
if verification_source is not None:
|
||||
verification_target = output / "artifact-verification.json"
|
||||
shutil.copyfile(verification_source, verification_target)
|
||||
checkpoint_target = output / checkpoint_source.name
|
||||
shutil.copyfile(checkpoint_source, checkpoint_target)
|
||||
report["artifact_verification"] = {
|
||||
"status": "passed",
|
||||
"report_path": verification_target.name,
|
||||
|
|
@ -136,13 +164,22 @@ def main() -> int:
|
|||
type=Path,
|
||||
default=ROOT / ".build" / "acceptance-kit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-manifest",
|
||||
type=Path,
|
||||
help="schema-2 manifest consumed by the artifact verifier",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--artifact-verification",
|
||||
type=Path,
|
||||
help="passing JSON output from verify_rayforge_windows_checkpoint.py",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
result = create_kit(args.output, artifact_verification=args.artifact_verification)
|
||||
result = create_kit(
|
||||
args.output,
|
||||
artifact_verification=args.artifact_verification,
|
||||
checkpoint_manifest=args.checkpoint_manifest,
|
||||
)
|
||||
print(f"kit={args.output.resolve()}")
|
||||
print(f"files={len(result['files'])}")
|
||||
print(f"repository_commit={result['repository_commit']}")
|
||||
|
|
|
|||
|
|
@ -67,14 +67,24 @@ def _validate_evidence(report_root: Path, records: list[dict]) -> list[str]:
|
|||
|
||||
|
||||
def _bound_json(report_root: Path, binding: dict, label: str) -> tuple[dict | None, list[str]]:
|
||||
value = binding.get("report_path") or binding.get("manifest_path") or ""
|
||||
value = (
|
||||
binding.get("report_path")
|
||||
or binding.get("manifest_path")
|
||||
or binding.get("path")
|
||||
or ""
|
||||
)
|
||||
relative = PurePosixPath(value)
|
||||
if not value or relative.is_absolute() or ".." in relative.parts:
|
||||
return None, [f"unsafe {label} path: {value!r}"]
|
||||
path = report_root.joinpath(*relative.parts)
|
||||
if not path.is_file():
|
||||
return None, [f"{label} is missing: {value}"]
|
||||
digest = binding.get("report_sha256") or binding.get("manifest_sha256") or ""
|
||||
digest = (
|
||||
binding.get("report_sha256")
|
||||
or binding.get("manifest_sha256")
|
||||
or binding.get("sha256")
|
||||
or ""
|
||||
)
|
||||
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if not SHA256.fullmatch(digest) or digest != actual:
|
||||
return None, [f"{label} SHA-256 mismatch: {value}"]
|
||||
|
|
@ -112,6 +122,13 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]:
|
|||
return errors
|
||||
if verification.get("status") != "passed":
|
||||
errors.append("artifact verification did not pass")
|
||||
if verification.get("schema_version") != 1:
|
||||
errors.append("unsupported artifact-verification schema")
|
||||
if (
|
||||
verification.get("verification")
|
||||
!= "Rayforge Windows no-device artifact verification"
|
||||
):
|
||||
errors.append("unexpected artifact-verification identity")
|
||||
if verification.get("hardware_access") is not False:
|
||||
errors.append("artifact verification does not preserve the hardware gate")
|
||||
if verification.get("executed_binaries") is not False:
|
||||
|
|
@ -120,12 +137,37 @@ def _validate_artifact_report(report_root: Path, binding: dict) -> list[str]:
|
|||
errors.append("artifact verification dropped the manual interaction gate")
|
||||
if verification.get("rayforge_commit") != RAYFORGE_COMMIT:
|
||||
errors.append("artifact verification uses the wrong Rayforge commit")
|
||||
if verification.get("addon") != "the_pirate_ray_jcz":
|
||||
errors.append("artifact verification uses the wrong addon")
|
||||
if SHA256.fullmatch(verification.get("bundle_sha256", "")) is None:
|
||||
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 verification.get("verified_addon_files") != EXPECTED_ADDON_FILES:
|
||||
errors.append("artifact verification did not verify the exact addon file set")
|
||||
checkpoint, checkpoint_errors = _bound_json(
|
||||
report_root,
|
||||
verification.get("checkpoint_manifest", {}),
|
||||
"checkpoint manifest",
|
||||
)
|
||||
errors.extend(checkpoint_errors)
|
||||
if checkpoint is not None:
|
||||
if checkpoint.get("schema_version") != 2:
|
||||
errors.append("unsupported checkpoint-manifest schema")
|
||||
if checkpoint.get("checkpoint") != "Rayforge Windows no-device bundle":
|
||||
errors.append("unexpected checkpoint-manifest identity")
|
||||
if checkpoint.get("addon") != verification.get("addon"):
|
||||
errors.append("checkpoint-manifest addon mismatch")
|
||||
if checkpoint.get("rayforge_commit") != verification.get("rayforge_commit"):
|
||||
errors.append("checkpoint-manifest Rayforge commit mismatch")
|
||||
if checkpoint.get("bundle", {}).get("sha256") != verification.get(
|
||||
"bundle_sha256"
|
||||
):
|
||||
errors.append("checkpoint-manifest bundle SHA-256 mismatch")
|
||||
if checkpoint.get("installer", {}).get("sha256") != verification.get(
|
||||
"installer_sha256"
|
||||
):
|
||||
errors.append("checkpoint-manifest installer SHA-256 mismatch")
|
||||
return errors
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -65,9 +65,14 @@ def _relative_below(path: PurePosixPath, root: PurePosixPath) -> PurePosixPath |
|
|||
|
||||
def verify(manifest_path: Path) -> dict:
|
||||
manifest_path = manifest_path.resolve()
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8-sig"))
|
||||
if manifest.get("schema_version") != 2:
|
||||
raise ValueError("unsupported checkpoint schema")
|
||||
if manifest.get("checkpoint") != "Rayforge Windows no-device bundle":
|
||||
raise ValueError("unexpected checkpoint identity")
|
||||
if manifest.get("addon") != "the_pirate_ray_jcz":
|
||||
raise ValueError("unexpected checkpoint addon")
|
||||
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:
|
||||
|
|
@ -143,8 +148,15 @@ def verify(manifest_path: Path) -> dict:
|
|||
verified_addon_files = len(packaged_addon)
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"verification": "Rayforge Windows no-device artifact verification",
|
||||
"status": "passed",
|
||||
"rayforge_commit": manifest["rayforge_commit"],
|
||||
"addon": manifest["addon"],
|
||||
"checkpoint_manifest": {
|
||||
"path": manifest_path.name,
|
||||
"sha256": _digest(manifest_bytes),
|
||||
},
|
||||
"hardware_access": False,
|
||||
"executed_binaries": False,
|
||||
"bundle_sha256": manifest["bundle"]["sha256"],
|
||||
|
|
|
|||
|
|
@ -16,8 +16,20 @@ from scripts.prepare_m1_acceptance_kit import (
|
|||
|
||||
class M1AcceptanceKitTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _verification(status="passed"):
|
||||
return {
|
||||
def _artifact_pair(root: Path, status="passed"):
|
||||
checkpoint = {
|
||||
"schema_version": 2,
|
||||
"checkpoint": "Rayforge Windows no-device bundle",
|
||||
"addon": "the_pirate_ray_jcz",
|
||||
"rayforge_commit": RAYFORGE_COMMIT,
|
||||
"bundle": {"sha256": "1" * 64},
|
||||
"installer": {"sha256": "2" * 64},
|
||||
}
|
||||
checkpoint_path = root / "rayforge-windows-checkpoint.json"
|
||||
checkpoint_path.write_text(json.dumps(checkpoint), encoding="utf-8")
|
||||
verification = {
|
||||
"schema_version": 1,
|
||||
"verification": "Rayforge Windows no-device artifact verification",
|
||||
"status": status,
|
||||
"hardware_access": False,
|
||||
"executed_binaries": False,
|
||||
|
|
@ -26,7 +38,15 @@ class M1AcceptanceKitTests(unittest.TestCase):
|
|||
"bundle_sha256": "1" * 64,
|
||||
"installer_sha256": "2" * 64,
|
||||
"verified_addon_files": EXPECTED_ADDON_FILES,
|
||||
"addon": "the_pirate_ray_jcz",
|
||||
"checkpoint_manifest": {
|
||||
"path": checkpoint_path.name,
|
||||
"sha256": hashlib.sha256(checkpoint_path.read_bytes()).hexdigest(),
|
||||
},
|
||||
}
|
||||
verification_path = root / "verification.json"
|
||||
verification_path.write_text(json.dumps(verification), encoding="utf-8")
|
||||
return verification_path, checkpoint_path
|
||||
|
||||
def test_kit_contains_only_allowlisted_public_inputs(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
|
|
@ -84,15 +104,13 @@ class M1AcceptanceKitTests(unittest.TestCase):
|
|||
def test_passing_artifact_verification_is_bound_into_report(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
root = Path(directory)
|
||||
verification = root / "verification.json"
|
||||
verification.write_text(
|
||||
json.dumps(self._verification()), encoding="utf-8"
|
||||
)
|
||||
verification, checkpoint = self._artifact_pair(root)
|
||||
output = root / "kit"
|
||||
create_kit(
|
||||
output,
|
||||
repository_commit="e" * 40,
|
||||
artifact_verification=verification,
|
||||
checkpoint_manifest=checkpoint,
|
||||
)
|
||||
copied = output / "artifact-verification.json"
|
||||
report = json.loads((output / "manual-report.json").read_text(encoding="utf-8"))
|
||||
|
|
@ -106,22 +124,20 @@ class M1AcceptanceKitTests(unittest.TestCase):
|
|||
def test_failed_artifact_verification_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
root = Path(directory)
|
||||
verification = root / "verification.json"
|
||||
verification.write_text(
|
||||
json.dumps(self._verification(status="failed")), encoding="utf-8"
|
||||
)
|
||||
verification, checkpoint = self._artifact_pair(root, status="failed")
|
||||
with self.assertRaisesRegex(ValueError, "did not pass"):
|
||||
create_kit(
|
||||
root / "kit",
|
||||
repository_commit="f" * 40,
|
||||
artifact_verification=verification,
|
||||
checkpoint_manifest=checkpoint,
|
||||
)
|
||||
|
||||
def test_wrong_rayforge_pin_or_missing_hashes_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
root = Path(directory)
|
||||
verification = root / "verification.json"
|
||||
result = self._verification()
|
||||
verification, checkpoint = self._artifact_pair(root)
|
||||
result = json.loads(verification.read_text(encoding="utf-8"))
|
||||
result["rayforge_commit"] = "0" * 40
|
||||
result["bundle_sha256"] = "not-a-hash"
|
||||
verification.write_text(json.dumps(result), encoding="utf-8")
|
||||
|
|
@ -130,6 +146,20 @@ class M1AcceptanceKitTests(unittest.TestCase):
|
|||
root / "kit",
|
||||
repository_commit="f" * 40,
|
||||
artifact_verification=verification,
|
||||
checkpoint_manifest=checkpoint,
|
||||
)
|
||||
|
||||
def test_changed_checkpoint_manifest_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
root = Path(directory)
|
||||
verification, checkpoint = self._artifact_pair(root)
|
||||
checkpoint.write_text("{}", encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "did not pass"):
|
||||
create_kit(
|
||||
root / "kit",
|
||||
repository_commit="f" * 40,
|
||||
artifact_verification=verification,
|
||||
checkpoint_manifest=checkpoint,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,19 @@ class M1ManualAcceptanceTests(unittest.TestCase):
|
|||
"manifest_sha256": hashlib.sha256(manifest_path.read_bytes()).hexdigest(),
|
||||
"repository_commit": "b" * 40,
|
||||
}
|
||||
checkpoint = {
|
||||
"schema_version": 2,
|
||||
"checkpoint": "Rayforge Windows no-device bundle",
|
||||
"addon": "the_pirate_ray_jcz",
|
||||
"rayforge_commit": RAYFORGE_COMMIT,
|
||||
"bundle": {"sha256": "1" * 64},
|
||||
"installer": {"sha256": "2" * 64},
|
||||
}
|
||||
checkpoint_path = root / "rayforge-windows-checkpoint.json"
|
||||
checkpoint_path.write_text(json.dumps(checkpoint), encoding="utf-8")
|
||||
verification = {
|
||||
"schema_version": 1,
|
||||
"verification": "Rayforge Windows no-device artifact verification",
|
||||
"status": "passed",
|
||||
"hardware_access": False,
|
||||
"executed_binaries": False,
|
||||
|
|
@ -51,6 +63,11 @@ class M1ManualAcceptanceTests(unittest.TestCase):
|
|||
"bundle_sha256": "1" * 64,
|
||||
"installer_sha256": "2" * 64,
|
||||
"verified_addon_files": EXPECTED_ADDON_FILES,
|
||||
"addon": "the_pirate_ray_jcz",
|
||||
"checkpoint_manifest": {
|
||||
"path": checkpoint_path.name,
|
||||
"sha256": hashlib.sha256(checkpoint_path.read_bytes()).hexdigest(),
|
||||
},
|
||||
}
|
||||
verification_path = root / "artifact-verification.json"
|
||||
verification_path.write_text(json.dumps(verification), encoding="utf-8")
|
||||
|
|
@ -163,6 +180,19 @@ class M1ManualAcceptanceTests(unittest.TestCase):
|
|||
self.assertIn("artifact verification uses the wrong Rayforge commit", result["errors"])
|
||||
self.assertIn("artifact verification has an invalid installer SHA-256", result["errors"])
|
||||
|
||||
def test_changed_checkpoint_manifest_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
root = Path(directory)
|
||||
report = self._passing(root)
|
||||
(root / "rayforge-windows-checkpoint.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
)
|
||||
result = validate(self._write(root, report))
|
||||
self.assertEqual("failed", result["status"])
|
||||
self.assertTrue(
|
||||
any("checkpoint manifest SHA-256 mismatch" in error for error in result["errors"])
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
|||
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
||||
result = verify(self._checkpoint(Path(directory)))
|
||||
self.assertEqual(result["status"], "passed")
|
||||
self.assertEqual(1, result["schema_version"])
|
||||
self.assertEqual("the_pirate_ray_jcz", result["addon"])
|
||||
self.assertRegex(result["checkpoint_manifest"]["sha256"], r"^[0-9a-f]{64}$")
|
||||
self.assertFalse(result["executed_binaries"])
|
||||
expected_count = sum(
|
||||
path.is_file() and "__pycache__" not in path.parts
|
||||
|
|
@ -132,6 +135,15 @@ class WindowsCheckpointVerifierTests(unittest.TestCase):
|
|||
with self.assertRaises(FileNotFoundError):
|
||||
verify(manifest)
|
||||
|
||||
def test_checkpoint_and_addon_identity_are_required(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"] = "foreign_addon"
|
||||
manifest.write_text(json.dumps(data), encoding="utf-8")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected checkpoint addon"):
|
||||
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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue