build: verify exact packaged addon contents
This commit is contained in:
parent
bf53e4f2fc
commit
644c99a9ca
9 changed files with 122 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue