From 415b00e15791e2dc8d1354d4a2089f56c5bbca02 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 14:27:34 +0200 Subject: [PATCH] build: pin upstream contracts and license notices --- README.md | 2 + docs/CODEX_HANDOFF.md | 12 +- docs/adr/0002-reuse-rayforge-and-balormk.md | 4 + ...3-project-license-and-upstream-boundary.md | 63 +++++++++ docs/research/m1-no-device-acceptance.md | 12 +- .../upstream-contract-and-license-audit.md | 91 +++++++++++++ docs/roadmap.md | 4 + docs/status/phase-0.md | 7 +- scripts/build_rayforge_windows_checkpoint.ps1 | 30 ++++- scripts/check_upstream_contracts.py | 127 ++++++++++++++++++ scripts/verify_rayforge_windows_checkpoint.py | 24 +++- tests/test_m1_acceptance_pipeline.py | 4 + tests/test_upstream_contracts.py | 87 ++++++++++++ tests/test_windows_checkpoint_verifier.py | 28 +++- third_party/README.md | 15 +++ third_party/licenses/MeerK40t-LICENSE.txt | 21 +++ third_party/licenses/Rayforge-LICENSE.txt | 17 +++ upstreams/meerk40t.json | 27 ++++ upstreams/rayforge.json | 37 +++++ 19 files changed, 598 insertions(+), 14 deletions(-) create mode 100644 docs/adr/0003-project-license-and-upstream-boundary.md create mode 100644 docs/research/upstream-contract-and-license-audit.md create mode 100644 scripts/check_upstream_contracts.py create mode 100644 tests/test_upstream_contracts.py create mode 100644 third_party/README.md create mode 100644 third_party/licenses/MeerK40t-LICENSE.txt create mode 100644 third_party/licenses/Rayforge-LICENSE.txt create mode 100644 upstreams/meerk40t.json diff --git a/README.md b/README.md index 4486a8f..cf583b9 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ project workspace or development destination. - [Rayforge material-recipe model spike](docs/research/rayforge-recipe-model-spike.md) - [M1 no-device build and acceptance checkpoint](docs/research/m1-no-device-acceptance.md) - [Proposed Rayforge/BalorMK reuse decision](docs/adr/0002-reuse-rayforge-and-balormk.md) +- [Proposed project license and upstream boundary](docs/adr/0003-project-license-and-upstream-boundary.md) +- [Upstream contract and license audit](docs/research/upstream-contract-and-license-audit.md) - [Live USB enumeration](docs/research/live-usb-enumeration-2026-10-17.md) - [LightBurn device-export analysis](docs/research/lightburn-device-exports-2026-10-19.md) - [Sanitized machine profile](docs/research/machine-profile.yaml) diff --git a/docs/CODEX_HANDOFF.md b/docs/CODEX_HANDOFF.md index 729c04e..0d7d1b8 100644 --- a/docs/CODEX_HANDOFF.md +++ b/docs/CODEX_HANDOFF.md @@ -19,7 +19,10 @@ The repository now provides: - a non-executing verifier for externally produced Windows artifacts; - a checksummed acceptance-kit generator containing only allowlisted public synthetic inputs; -- a fail-closed manual desktop-acceptance report and validator. +- a fail-closed manual desktop-acceptance report and validator; +- pinned upstream contract/license manifests and a drift checker; +- a proposed MIT project-license decision in ADR 0003; Kaz has not accepted + it, so the repository remains unlicensed. Run the ordinary checkpoint with the bundled Codex Python or another supported Python interpreter and a writable `TEMP`/`TMP`: @@ -55,6 +58,7 @@ order: 4. `scripts/validate_m1_manual_acceptance.py`. If no artifact is available, do not install a build environment here. The next -useful input is the private known-working `.lbrn2` collection. Until either -external gate arrives, ADR 0002 remains Proposed and no controller backend, -USB discovery, physical framing, galvo motion or laser output is authorized. +useful input is the private known-working `.lbrn2` collection or Kaz's explicit +decision on ADR 0003. Until either external gate arrives, ADR 0002 remains +Proposed and no controller backend, USB discovery, physical framing, galvo +motion or laser output is authorized. diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 83ac32d..1fd8999 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -123,6 +123,10 @@ Subsequent offline evidence: over addon lifecycle, workflow, layers, recipes, encoding, negative cases, cancellation/recovery and synthetic imports. The packaged Windows GUI and a known-working private project remain external gates. +- [Upstream contract and license audit](../research/upstream-contract-and-license-audit.md): + both pins and license notices are now machine-verifiable; the documented + addon lifecycle is usable, but driver registration and cleanup still rely + on an internal registry seam that should be replaced upstream. These results satisfy the current model, buffering and no-device integration proofs. They do not yet satisfy the packaged human-interaction or real-project diff --git a/docs/adr/0003-project-license-and-upstream-boundary.md b/docs/adr/0003-project-license-and-upstream-boundary.md new file mode 100644 index 0000000..ad7cde0 --- /dev/null +++ b/docs/adr/0003-project-license-and-upstream-boundary.md @@ -0,0 +1,63 @@ +# ADR 0003: Project license and upstream attribution boundary + +- Status: Proposed +- Date: 2026-10-21 + +## Context + +The Pirate Ray is intended to become open source, but the repository currently +has no project license. Rayforge and MeerK40t are both pinned under MIT terms, +and the leading architecture is a Rayforge addon informed by BalorMK behavior. +ADR 0002 forbids copied upstream source until license compatibility and exact +attribution are recorded. + +## Proposed decision + +1. License original The Pirate Ray code under MIT. +2. Keep exact upstream repositories, revisions, license notices and contract + hashes in the public repository. +3. Include applicable notices in every source or binary distribution set. +4. Treat Rayforge's documented addon API as the preferred host boundary. +5. Isolate any unavoidable internal Rayforge registry access behind one + compatibility adapter and remove it when a supported upstream API exists. +6. Use BalorMK as a pinned behavioral oracle until a separately reviewed + decision authorizes adaptation of specific MIT-licensed implementation + files. + +## Why MIT is proposed + +MIT matches both principal upstreams, is compatible with contributing the +addon upstream, and imposes a small, auditable notice obligation. It does not +solve safety, warranty, trademark or third-party-data questions; those remain +separate project responsibilities. + +## Consequences + +- A root project `LICENSE` can be added only after Kaz accepts this ADR. +- Source provenance becomes part of code review and release acceptance. +- A Windows installer may not be published alone; its verified license + sidecar and checkpoint manifest are part of the distribution set. +- BalorMK code adaptation must identify source files and preserve the + MeerK40t notice. +- A Rayforge upgrade fails closed when a pinned license, source hash or + required symbol changes. + +## Alternatives considered + +- **Remain unlicensed:** acceptable for research snapshots but blocks the + intended open-source product and upstream code adaptation. +- **Apache-2.0:** provides an explicit patent grant but adds notice and license + complexity without a current upstream requirement. +- **GPL-family license:** may be appropriate for stronger reciprocity, but it + is a larger product-policy decision and is not required by the current MIT + dependencies. + +## Acceptance + +Kaz must explicitly choose the project license. Until then this ADR remains +Proposed, the README's no-license statement remains authoritative, and no root +`LICENSE` file is created. + +See the +[upstream contract and license audit](../research/upstream-contract-and-license-audit.md) +for the evidence and API-stability assessment. diff --git a/docs/research/m1-no-device-acceptance.md b/docs/research/m1-no-device-acceptance.md index 8244f64..914019e 100644 --- a/docs/research/m1-no-device-acceptance.md +++ b/docs/research/m1-no-device-acceptance.md @@ -51,11 +51,13 @@ python scripts/verify_rayforge_windows_checkpoint.py ` --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. +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. +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. ## Automated acceptance diff --git a/docs/research/upstream-contract-and-license-audit.md b/docs/research/upstream-contract-and-license-audit.md new file mode 100644 index 0000000..aa7fa86 --- /dev/null +++ b/docs/research/upstream-contract-and-license-audit.md @@ -0,0 +1,91 @@ +# Upstream contract and license audit + +- Date: 2026-10-21 +- Scope: pinned Rayforge and MeerK40t revisions +- Hardware access: none +- Legal status: engineering record, not legal advice + +## Result + +Both pinned checkouts declare MIT licensing and pass the machine-readable +contract check. Their exact repositories, commits, normalized license hashes, +source-file hashes and required symbols are recorded in +`upstreams/rayforge.json` and `upstreams/meerk40t.json`. + +Run the check against separately obtained source trees: + +```powershell +python scripts/check_upstream_contracts.py ` + --rayforge ` + --meerk40t ` + --output .build/artifacts/upstream-contracts.json +``` + +The current pinned trees pass. The command reads source and Git metadata only; +it imports neither project and cannot discover or open hardware. + +## License observations + +| Project | Pin | Declared license | Notice observation | +| --- | --- | --- | --- | +| Rayforge | `c28918c87f8273dd1d244f5a057addc192807fa5` | MIT | `pyproject.toml` names Samuel Abels as author; the pinned `LICENSE` text contains the MIT grant and warranty disclaimer but no explicit copyright line | +| MeerK40t | `d511944e08788e88077727f682957759884ff5ca` | MIT | The pinned notice states `Copyright (c) 2021 meerk40t` | + +Exact notice copies live under `third_party/licenses/`. They are notices, not +a license grant for The Pirate Ray's own code. No upstream source is vendored +by adding them. + +MIT permits use, modification and redistribution, but requires the copyright +and permission notice to accompany copies or substantial portions. Therefore: + +- every Rayforge-based ZIP and installer distribution set must include the + pinned Rayforge notice; +- a distribution containing adapted or copied BalorMK/MeerK40t code must also + include the MeerK40t notice; +- attribution must name the upstream project and exact revision without + implying endorsement; +- The Pirate Ray still needs its own explicit license before original project + code is offered under open-source terms. + +Checkpoint schema 2 now requires a verified Rayforge license sidecar and the +same notice inside the portable ZIP. The installer must be distributed with +its manifest and sidecar; installer-only publication is not accepted. + +## API stability assessment + +| Seam | Classification | Assessment | +| --- | --- | --- | +| Rayforge addon API version 23, `hookimpl`, `on_unload` | Documented addon API | Suitable compatibility boundary at the pinned revision | +| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration | +| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades | +| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists | +| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism | +| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API | + +The immediate upstream contribution should be a narrow, addon-aware driver +registry contract with symmetric registration and cleanup. Until upstream +accepts such a contract, all internal registry access must remain isolated in +one compatibility adapter and guarded by the hashes/symbol tests. + +## Code-origin boundary + +The current PirateRay addon is a no-device clean-room spike. It imports +Rayforge host interfaces and compares independently generated traces with the +pinned BalorMK mock behavior. It does not vendor Rayforge or MeerK40t source. + +Before adapting any BalorMK implementation code: + +1. accept a project license compatible with MIT; +2. record the exact source files and revisions used; +3. preserve the MeerK40t notice in source and distributions; +4. keep transport code separate from the independently tested encoder; and +5. review the resulting diff for copied comments, identifiers and substantial + structure rather than assuming a rewrite has no attribution duty. + +## Recommendation requiring owner decision + +MIT is the simplest project-license candidate because both principal +upstreams use MIT and the intended addon is meant for upstream contribution. +This audit does not select it. Kaz must explicitly approve the project license +before a root `LICENSE` file is added or upstream implementation code is +adapted. diff --git a/docs/roadmap.md b/docs/roadmap.md index 269198e..23a9127 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -331,6 +331,10 @@ 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. +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; project license +selection remains an explicit owner decision in ADR 0003. ## Decision rules diff --git a/docs/status/phase-0.md b/docs/status/phase-0.md index 45b41de..8f642ce 100644 --- a/docs/status/phase-0.md +++ b/docs/status/phase-0.md @@ -71,7 +71,7 @@ evidence for: the pinned BalorMK mock values; - fail-closed profile and encoder negative cases; - Rayforge workflow-model serialization and preview behavior for power, - frequency and pulse width. + frequency and pulse width; - 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; @@ -84,7 +84,10 @@ evidence for: 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. + run directory; +- pinned Rayforge and MeerK40t license/source contracts with drift tests, plus + checkpoint schema 2 requiring the Rayforge MIT notice in the portable ZIP + and beside the installer manifest. The remaining M1 gates are deliberately explicit: diff --git a/scripts/build_rayforge_windows_checkpoint.ps1 b/scripts/build_rayforge_windows_checkpoint.ps1 index 595f53b..cf951f2 100644 --- a/scripts/build_rayforge_windows_checkpoint.ps1 +++ b/scripts/build_rayforge_windows_checkpoint.ps1 @@ -74,6 +74,25 @@ if (-not (Test-Path -LiteralPath $installer)) { } New-Item -ItemType Directory -Force -Path $ArtifactRoot | Out-Null +$licenseDirectory = Join-Path $bundleDirectory "THIRD_PARTY_LICENSES" +$rayforgeLicense = Join-Path $checkout "LICENSE" +$normalizedLicense = [IO.File]::ReadAllText($rayforgeLicense).Replace("`r`n", "`n").Replace("`r", "`n") +$normalizedLicenseBytes = [Text.Encoding]::UTF8.GetBytes($normalizedLicense) +$rayforgeLicenseHash = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($normalizedLicenseBytes) +).ToLowerInvariant() +if ($rayforgeLicenseHash -ne $upstreamManifest.license_file.sha256_lf) { + throw "Pinned Rayforge license hash mismatch" +} +$rayforgeNotice = Join-Path $repositoryRoot "third_party\licenses\Rayforge-LICENSE.txt" +$bundledRayforgeLicense = Join-Path $licenseDirectory "Rayforge-LICENSE.txt" +$sidecarRayforgeLicense = Join-Path $ArtifactRoot "Rayforge-LICENSE.txt" +if ($PSCmdlet.ShouldProcess($licenseDirectory, "Stage Rayforge license notice")) { + New-Item -ItemType Directory -Force -Path $licenseDirectory | Out-Null + Copy-Item -LiteralPath $rayforgeNotice -Destination $bundledRayforgeLicense -Force + Copy-Item -LiteralPath $rayforgeNotice -Destination $sidecarRayforgeLicense -Force +} + $archive = Join-Path $ArtifactRoot "$bundleName.zip" if ($PSCmdlet.ShouldProcess($archive, "Create portable checkpoint archive")) { Compress-Archive -LiteralPath $bundleDirectory -DestinationPath $archive -Force @@ -88,7 +107,7 @@ $addonFiles = Get-ChildItem -LiteralPath $addonSource -Recurse -File | } } $result = [ordered]@{ - schema_version = 1 + schema_version = 2 checkpoint = "Rayforge Windows no-device bundle" rayforge_commit = $verifiedCommit addon = "the_pirate_ray_jcz" @@ -106,6 +125,15 @@ $result = [ordered]@{ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant() bytes = (Get-Item -LiteralPath $installer).Length } + license_files = @( + [ordered]@{ + upstream = "Rayforge" + path = Split-Path -Leaf $sidecarRayforgeLicense + bundle_path = "THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt" + sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $sidecarRayforgeLicense).Hash.ToLowerInvariant() + bytes = (Get-Item -LiteralPath $sidecarRayforgeLicense).Length + } + ) manual_interaction_required = $true } $manifestPath = Join-Path $ArtifactRoot "rayforge-windows-checkpoint.json" diff --git a/scripts/check_upstream_contracts.py b/scripts/check_upstream_contracts.py new file mode 100644 index 0000000..d1893d8 --- /dev/null +++ b/scripts/check_upstream_contracts.py @@ -0,0 +1,127 @@ +"""Verify pinned upstream licenses, source hashes, and required API symbols.""" + +from __future__ import annotations + +import argparse +import ast +import hashlib +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _text_sha256_lf(path: Path) -> str: + normalized = path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _git_commit(path: Path) -> str: + return subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _symbols(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + symbols: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + symbols.add(node.name) + if isinstance(node, ast.ClassDef): + for child in node.body: + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): + symbols.add(f"{node.name}.{child.name}") + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name): + symbols.add(target.id) + return symbols + + +def inspect_tree(manifest: dict, checkout: Path, observed_commit: str) -> dict: + failures = [] + if observed_commit != manifest["commit"]: + failures.append( + f"commit mismatch: expected {manifest['commit']}, found {observed_commit}" + ) + + license_record = manifest["license_file"] + license_path = checkout / license_record["path"] + if not license_path.is_file(): + failures.append(f"missing license file: {license_record['path']}") + elif _text_sha256_lf(license_path) != license_record["sha256_lf"]: + failures.append(f"license hash mismatch: {license_record['path']}") + + contracts = [] + for record in manifest["contract_files"]: + path = checkout / record["path"] + missing_symbols = [] + if not path.is_file(): + failures.append(f"missing contract file: {record['path']}") + else: + if _sha256(path) != record["sha256"]: + failures.append(f"contract hash mismatch: {record['path']}") + available = _symbols(path) + missing_symbols = sorted(set(record["required_symbols"]) - available) + if missing_symbols: + failures.append( + f"missing symbols in {record['path']}: {', '.join(missing_symbols)}" + ) + contracts.append({ + "path": record["path"], + "stability": record["stability"], + "required_symbols": record["required_symbols"], + "missing_symbols": missing_symbols, + }) + + return { + "name": manifest["name"], + "repository": manifest["repository"], + "commit": observed_commit, + "license": manifest["license"], + "license_sha256_lf": license_record["sha256_lf"], + "status": "passed" if not failures else "failed", + "failures": failures, + "contracts": contracts, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rayforge", type=Path, required=True) + parser.add_argument("--meerk40t", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + results = [] + for name, checkout in (("rayforge", args.rayforge), ("meerk40t", args.meerk40t)): + manifest = json.loads( + (ROOT / "upstreams" / f"{name}.json").read_text(encoding="utf-8") + ) + resolved = checkout.resolve() + results.append(inspect_tree(manifest, resolved, _git_commit(resolved))) + report = { + "status": "passed" if all(item["status"] == "passed" for item in results) else "failed", + "hardware_access": False, + "upstreams": results, + } + rendered = json.dumps(report, indent=2, sort_keys=True) + "\n" + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8", newline="\n") + print(f"status={report['status']}") + return 0 if report["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_rayforge_windows_checkpoint.py b/scripts/verify_rayforge_windows_checkpoint.py index 2724f29..8756829 100644 --- a/scripts/verify_rayforge_windows_checkpoint.py +++ b/scripts/verify_rayforge_windows_checkpoint.py @@ -21,6 +21,11 @@ def _digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def _text_digest_lf(data: bytes) -> str: + normalized = data.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + return hashlib.sha256(normalized.encode("utf-8")).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: @@ -42,7 +47,7 @@ def _check_file(root: Path, record: dict, label: str) -> 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: + if manifest.get("schema_version") != 2: raise ValueError("unsupported checkpoint schema") if manifest.get("rayforge_commit") != UPSTREAM["commit"]: raise ValueError("Rayforge commit does not match the repository pin") @@ -54,6 +59,13 @@ def verify(manifest_path: Path) -> dict: artifact_root = manifest_path.parent bundle = _check_file(artifact_root, manifest["bundle"], "bundle") installer = _check_file(artifact_root, manifest["installer"], "installer") + license_records = manifest.get("license_files", []) + if len(license_records) != 1 or license_records[0].get("upstream") != "Rayforge": + raise ValueError("checkpoint must include the Rayforge license sidecar") + license_path = _check_file(artifact_root, license_records[0], "Rayforge license") + expected_license_hash = UPSTREAM["license_file"]["sha256_lf"] + if _text_digest_lf(license_path.read_bytes()) != expected_license_hash: + raise ValueError("Rayforge license does not match the pinned upstream") packaged_root = PurePosixPath(manifest["addon_packaged_root"]) if packaged_root.is_absolute() or ".." in packaged_root.parts: raise ValueError("unsafe packaged addon root") @@ -70,6 +82,15 @@ def verify(manifest_path: Path) -> dict: executable = manifest["bundle"]["path"].removesuffix(".zip") + ".exe" if not any(path.name == executable for path in files): raise ValueError("bundle application executable is missing") + bundle_license = PurePosixPath(license_records[0]["bundle_path"]) + license_matches = [ + path for path in files + if tuple(path.parts[-len(bundle_license.parts):]) == bundle_license.parts + ] + if len(license_matches) != 1: + raise ValueError("Rayforge license is missing from the portable bundle") + 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"]: @@ -100,6 +121,7 @@ def verify(manifest_path: Path) -> dict: "verified_addon_files": verified_addon_files, "manual_interaction_required": True, "installer_name": installer.name, + "license_sidecar": license_path.name, } diff --git a/tests/test_m1_acceptance_pipeline.py b/tests/test_m1_acceptance_pipeline.py index be1b5fa..35311f8 100644 --- a/tests/test_m1_acceptance_pipeline.py +++ b/tests/test_m1_acceptance_pipeline.py @@ -44,6 +44,9 @@ class M1AcceptancePipelineTests(unittest.TestCase): self.assertIn("rayforge-addon-the-pirate-ray-jcz", source) self.assertIn("Compress-Archive", source) self.assertIn("Get-FileHash -Algorithm SHA256", source) + self.assertIn("schema_version = 2", source) + self.assertIn("Rayforge-LICENSE.txt", source) + self.assertIn("license_files", source) def test_acceptance_has_no_hardware_commands(self): source = ACCEPTANCE.read_text(encoding="utf-8").lower() @@ -60,6 +63,7 @@ class M1AcceptancePipelineTests(unittest.TestCase): self.assertIn("zipfile.ZipFile", source) self.assertIn("Rayforge commit does not match", source) self.assertIn('"executed_binaries": False', source) + self.assertIn("license sidecar", source) self.assertNotIn("subprocess", source) diff --git a/tests/test_upstream_contracts.py b/tests/test_upstream_contracts.py new file mode 100644 index 0000000..748d1b6 --- /dev/null +++ b/tests/test_upstream_contracts.py @@ -0,0 +1,87 @@ +import copy +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.check_upstream_contracts import ROOT, inspect_tree + + +class UpstreamContractTests(unittest.TestCase): + def _tree(self, root: Path) -> tuple[dict, str]: + license_path = root / "LICENSE" + api_path = root / "package" / "api.py" + api_path.parent.mkdir(parents=True) + license_path.write_text("synthetic MIT text\n", encoding="utf-8") + api_path.write_text( + "VERSION = 3\nclass Registry:\n def unregister(self):\n pass\n", + encoding="utf-8", + ) + commit = "a" * 40 + manifest = { + "name": "Synthetic upstream", + "repository": "https://example.invalid/upstream.git", + "commit": commit, + "license": "MIT", + "license_file": { + "path": "LICENSE", + "sha256_lf": hashlib.sha256(b"synthetic MIT text\n").hexdigest(), + }, + "contract_files": [{ + "path": "package/api.py", + "sha256": hashlib.sha256(api_path.read_bytes()).hexdigest(), + "stability": "documented-api", + "required_symbols": ["VERSION", "Registry", "Registry.unregister"], + }], + } + return manifest, commit + + def test_matching_tree_passes(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + manifest, commit = self._tree(root) + result = inspect_tree(manifest, root, commit) + self.assertEqual(result["status"], "passed") + self.assertEqual(result["failures"], []) + + def test_commit_hash_and_symbol_drift_fail(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + manifest, _ = self._tree(root) + changed = copy.deepcopy(manifest) + changed["contract_files"][0]["required_symbols"].append("Registry.missing") + result = inspect_tree(changed, root, "b" * 40) + self.assertEqual(result["status"], "failed") + self.assertTrue(any("commit mismatch" in item for item in result["failures"])) + self.assertTrue(any("Registry.missing" in item for item in result["failures"])) + + def test_license_drift_fails(self): + with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory: + root = Path(directory) + manifest, commit = self._tree(root) + (root / "LICENSE").write_text("changed\n", encoding="utf-8") + result = inspect_tree(manifest, root, commit) + self.assertEqual(result["status"], "failed") + self.assertTrue(any("license hash mismatch" in item for item in result["failures"])) + + def test_real_manifests_pin_mit_license_and_stability(self): + for name in ("rayforge", "meerk40t"): + manifest = json.loads( + (ROOT / "upstreams" / f"{name}.json").read_text(encoding="utf-8") + ) + self.assertEqual(manifest["license"], "MIT") + self.assertEqual(len(manifest["commit"]), 40) + self.assertEqual(len(manifest["license_file"]["sha256_lf"]), 64) + self.assertTrue(manifest["contract_files"]) + self.assertTrue(all(item["stability"] for item in manifest["contract_files"])) + notice = ROOT / "third_party" / "licenses" / f"{manifest['name']}-LICENSE.txt" + normalized = notice.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + self.assertEqual( + hashlib.sha256(normalized.encode("utf-8")).hexdigest(), + manifest["license_file"]["sha256_lf"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_windows_checkpoint_verifier.py b/tests/test_windows_checkpoint_verifier.py index 1620d8f..c0c5e93 100644 --- a/tests/test_windows_checkpoint_verifier.py +++ b/tests/test_windows_checkpoint_verifier.py @@ -19,17 +19,28 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): "rayforge-addon.yaml": b"name: the_pirate_ray_jcz\n", "pirate_ray_jcz/worker.py": b"TRANSPORT = None\n", } + upstream = json.loads( + (ROOT / "upstreams" / "rayforge.json").read_text(encoding="utf-8") + ) + license_data = (ROOT / "third_party" / "licenses" / "Rayforge-LICENSE.txt").read_bytes() + self.assertEqual(hashlib.sha256(license_data).hexdigest(), upstream["license_file"]["sha256_lf"]) 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) + archive.writestr( + f"{bundle_name[:-4]}/THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt", + license_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") + license_sidecar = root / "Rayforge-LICENSE.txt" + license_sidecar.write_bytes(license_data) manifest = { - "schema_version": 1, + "schema_version": 2, "checkpoint": "Rayforge Windows no-device bundle", "rayforge_commit": UPSTREAM["commit"], "addon": "the_pirate_ray_jcz", @@ -50,6 +61,13 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): "sha256": hashlib.sha256(installer.read_bytes()).hexdigest(), "bytes": installer.stat().st_size, }, + "license_files": [{ + "upstream": "Rayforge", + "path": license_sidecar.name, + "bundle_path": "THIRD_PARTY_LICENSES/Rayforge-LICENSE.txt", + "sha256": hashlib.sha256(license_data).hexdigest(), + "bytes": len(license_data), + }], "manual_interaction_required": True, } path = root / "rayforge-windows-checkpoint.json" @@ -86,6 +104,14 @@ class WindowsCheckpointVerifierTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "one filename"): verify(manifest) + def test_missing_license_sidecar_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["license_files"][0]["path"]).unlink() + with self.assertRaises(FileNotFoundError): + verify(manifest) + if __name__ == "__main__": unittest.main() diff --git a/third_party/README.md b/third_party/README.md new file mode 100644 index 0000000..e44ae4a --- /dev/null +++ b/third_party/README.md @@ -0,0 +1,15 @@ +# Third-party notices + +This directory contains exact license notices for pinned upstream projects +used by the offline research and integration work. It does not vendor their +source code. + +| Project | Pinned revision | License notice | +| --- | --- | --- | +| Rayforge | `c28918c87f8273dd1d244f5a057addc192807fa5` | `licenses/Rayforge-LICENSE.txt` | +| MeerK40t | `d511944e08788e88077727f682957759884ff5ca` | `licenses/MeerK40t-LICENSE.txt` | + +The expected UTF-8/LF-normalized hashes and source repositories are recorded in +`upstreams/rayforge.json` and `upstreams/meerk40t.json`. Inclusion of a notice +does not mean upstream source is included in The Pirate Ray, nor does it select +a license for The Pirate Ray's own code. diff --git a/third_party/licenses/MeerK40t-LICENSE.txt b/third_party/licenses/MeerK40t-LICENSE.txt new file mode 100644 index 0000000..e29c708 --- /dev/null +++ b/third_party/licenses/MeerK40t-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 meerk40t + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/licenses/Rayforge-LICENSE.txt b/third_party/licenses/Rayforge-LICENSE.txt new file mode 100644 index 0000000..969d061 --- /dev/null +++ b/third_party/licenses/Rayforge-LICENSE.txt @@ -0,0 +1,17 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/upstreams/meerk40t.json b/upstreams/meerk40t.json new file mode 100644 index 0000000..d2452dd --- /dev/null +++ b/upstreams/meerk40t.json @@ -0,0 +1,27 @@ +{ + "name": "MeerK40t", + "repository": "https://github.com/meerk40t/meerk40t.git", + "commit": "d511944e08788e88077727f682957759884ff5ca", + "license": "MIT", + "license_file": { + "path": "LICENSE", + "sha256_lf": "b0cb1df6963e70b7dc3735244360ef6686c4d2aa6288b326a0f7c93014f39cd6", + "copyright_notice_present": true, + "notice": "Copyright (c) 2021 meerk40t" + }, + "contract_files": [ + { + "path": "meerk40t/balormk/controller.py", + "sha256": "257f42e1b48b7cff45df63e2ad6db4becb87140c61a1e04f1168a5edabc11fcf", + "stability": "application-internal-reference", + "required_symbols": ["GalvoController", "GalvoController.connect_if_needed", "GalvoController.send", "listEndOfList", "listMarkTo", "listQSwitchPeriod"] + }, + { + "path": "meerk40t/balormk/mock_connection.py", + "sha256": "6c5f361a2474bc5da2660809029ee01f6f8723fe87e7cceb79ce69e80872aad2", + "stability": "application-internal-test-seam", + "required_symbols": ["MockConnection", "MockConnection.set_implied_response"] + } + ], + "purpose": "Pinned BalorMK reference and forced-mock oracle for offline JCZ validation" +} diff --git a/upstreams/rayforge.json b/upstreams/rayforge.json index 1639690..d6cf0d5 100644 --- a/upstreams/rayforge.json +++ b/upstreams/rayforge.json @@ -3,5 +3,42 @@ "repository": "https://github.com/barebaric/rayforge.git", "commit": "c28918c87f8273dd1d244f5a057addc192807fa5", "license": "MIT", + "license_file": { + "path": "LICENSE", + "sha256_lf": "f9c4c77baa3828004ee54b8a4f2db2e88ed44a6237a493965bf551fac0fcb62d", + "copyright_notice_present": false + }, + "contract_files": [ + { + "path": "rayforge/core/hooks.py", + "sha256": "e9f488361f120a848e89dd25eb122f9aedfb2313bfce8e60f1d252d92df7cfdc", + "stability": "documented-addon-api", + "required_symbols": ["MINIMUM_API_VERSION", "PLUGIN_API_VERSION", "RayforgeSpecs.on_unload", "RayforgeSpecs.register_machines"] + }, + { + "path": "rayforge/machine/driver/__init__.py", + "sha256": "06e733b8814118e3b3bfda28a6b2d6d157c5562802bd5a2294574f3b305488ef", + "stability": "internal-registry", + "required_symbols": ["drivers", "driver_by_classname", "register_driver", "get_driver_cls"] + }, + { + "path": "rayforge/machine/driver/driver.py", + "sha256": "8114ec5d2cf63a4eb98eea3db68d612fb00f1ca7add89f433c8f90758f103a5e", + "stability": "imported-host-interface", + "required_symbols": ["Driver"] + }, + { + "path": "rayforge/pipeline/encoder/base.py", + "sha256": "b18f87dc57655379ebdbce4c64b0472b589c7182b3a20423fa93ef22c9393734", + "stability": "imported-host-interface", + "required_symbols": ["EncodedOutput", "MachineCodeOpMap", "OpsEncoder"] + }, + { + "path": "rayforge/addon_mgr/addon_manager.py", + "sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b", + "stability": "host-lifecycle-implementation", + "required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"] + } + ], "purpose": "Pinned host application for offline reuse and JCZ addon validation" }