build: verify portable Windows checkpoints
This commit is contained in:
parent
f8d3c889e3
commit
28de151e65
9 changed files with 251 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
121
scripts/verify_rayforge_windows_checkpoint.py
Normal file
121
scripts/verify_rayforge_windows_checkpoint.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
91
tests/test_windows_checkpoint_verifier.py
Normal file
91
tests/test_windows_checkpoint_verifier.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue