From 9d4c3846147788d59b93496daa9749e46e713b97 Mon Sep 17 00:00:00 2001 From: plancher Date: Sat, 8 Aug 2026 14:24:50 -0400 Subject: [PATCH 1/2] schema-2 sharded receipts: per-shard fingerprints, carry-forward with ancestry+fingerprint soundness, policy-gated at verify (allow_carried default false) --- CHANGELOG.md | 15 ++ docs/security_model.md | 12 ++ docs/sharding.md | 47 ++++++ pyproject.toml | 2 +- src/pytest_gpu_proof/__init__.py | 2 +- src/pytest_gpu_proof/cli.py | 11 ++ src/pytest_gpu_proof/config.py | 15 ++ src/pytest_gpu_proof/merge.py | 116 +++++++++++++- src/pytest_gpu_proof/plugin.py | 15 ++ src/pytest_gpu_proof/receipt.py | 27 +++- src/pytest_gpu_proof/verify.py | 75 ++++++++- tests/test_sharding.py | 265 +++++++++++++++++++++++++++++++ 12 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 tests/test_sharding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d225..6058a86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to pytest-gpu-proof are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/) (pre-1.0: minor bumps may break). +## [0.3.0] — unreleased + +### Added +- Schema `"2"` sharded receipts: `--gpu-proof-shard NAME` + + `--gpu-proof-shard-fingerprint-paths` emit a per-shard narrow fingerprint; + `gpu-proof merge` unions schema-2 shards (unique names enforced). +- Verifiable carry-forward: `gpu-proof merge --carry-from OLD` grafts shards + absent from the fresh inputs iff the old commit is an ancestor AND the + shard's narrow fingerprint recomputes clean; grafted shards are marked + `carried` and the verifier rejects them unless the policy sets + `allow_carried: true` (bounded by `carried_max_age_days`, default 30). +- Verifier: schema-2 checks (per-shard fingerprint recompute, membership + partition of `tests[]`, carried-shard policy gate). Schema-1 receipts are + unchanged and a schema-1 receipt carrying a `shards` block is rejected. + ## [0.2.0] — 2026-08-07 ### Added diff --git a/docs/security_model.md b/docs/security_model.md index 3387416..4b46e63 100644 --- a/docs/security_model.md +++ b/docs/security_model.md @@ -73,3 +73,15 @@ that a specific GPU executed a specific workload in a verified environment. This is out of scope for v1 but the receipt format is designed to be extendable — a `hardware_attestation` block could be added to the `environment` section in a future version without breaking existing receipts. + +## Carried shards (schema 2) + +A carried shard is an attestation about a **prior** run: the tests passed at an +ancestor commit, and the shard's declared input paths are byte-identical at the +verified commit (the verifier recomputes this; it is not taken on faith). What +is NOT re-established: that the prior run's environment still exists, or that +paths *outside* the shard's declared fingerprint didn't change its behavior — +declaring too-narrow shard paths weakens the claim, exactly like declaring +too-narrow global fingerprint paths. That is why `allow_carried` defaults to +**false**: accepting carried shards is an explicit policy decision, bounded by +`carried_max_age_days`. diff --git a/docs/sharding.md b/docs/sharding.md index 6af4af8..9b4ae92 100644 --- a/docs/sharding.md +++ b/docs/sharding.md @@ -60,3 +60,50 @@ an **attestation by the merger**. Shard signatures are recorded as provenance but not re-verified at merge time (merging is offline); the merged signature is what CI verifies. If shards were signed by someone else, verification of the merged receipt attests that *you* vouch for the union. + +## Per-shard fingerprints & carry-forward (schema 2) + +Declare each invocation as a **shard** and the receipt becomes schema `"2"`, +carrying that shard's own *narrow* fingerprint over the paths you declare: + +```bash +pytest tests/gpu/test_a.py --gpu-proof-enable \ + --gpu-proof-shard=test_a \ + --gpu-proof-shard-fingerprint-paths=tests/gpu/test_a.py,src/kernels_a \ + --gpu-proof-out=receipts/a.json +``` + +`gpu-proof merge` unions schema-2 shards exactly like schema-1 receipts (shard +names must be unique). The new capability is **carry-forward**: + +```bash +gpu-proof merge --out gpu-proof.json --carry-from last-green/gpu-proof.json \ + --repo . receipts/*.json +``` + +Shards present in the old receipt but absent from the fresh inputs are grafted +in, **marked `carried`**, iff: + +1. the old receipt's commit is an **ancestor** of the fresh one (same history), and +2. the shard's narrow fingerprint **recomputes identical** against the current + tree — the inputs that shard proved are unchanged. + +A shard whose inputs changed refuses to carry (re-run it). Freshly re-run +shards always win over old ones. + +### Verification of schema-2 receipts + +`gpu-proof verify` additionally checks, for every shard: the narrow +fingerprint recomputes clean at the verifying tree, and shard membership +exactly partitions `tests[]`. **Carried shards are rejected by default** — the +policy must opt in: + +```yaml +allow_carried: true # default false — the trust boundary +carried_max_age_days: 30 # carried shard's ORIGINAL run must be fresher +``` + +A receipt with carried shards verified under `allow_carried: true` means: +*every test either ran at this commit, or ran at an ancestor commit on inputs +that are provably byte-identical today, within the age window* — and the +merger signed for that claim. diff --git a/pyproject.toml b/pyproject.toml index 14e3aab..94c7845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pytest-gpu-proof" -version = "0.2.0" +version = "0.3.0" description = "pytest plugin for GPU equivalence testing with signed receipts verified via GitHub SSH keys" readme = "README.md" requires-python = ">=3.11" diff --git a/src/pytest_gpu_proof/__init__.py b/src/pytest_gpu_proof/__init__.py index d3ec452..493f741 100644 --- a/src/pytest_gpu_proof/__init__.py +++ b/src/pytest_gpu_proof/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/pytest_gpu_proof/cli.py b/src/pytest_gpu_proof/cli.py index 64516a5..2817127 100644 --- a/src/pytest_gpu_proof/cli.py +++ b/src/pytest_gpu_proof/cli.py @@ -97,6 +97,15 @@ def main(): mp.add_argument("--unsigned", action="store_true", default=False, help="Write signature: null — the merged receipt then " "verifies only with --allow-unsigned, loudly") + mp.add_argument("--carry-from", default=None, metavar="RECEIPT", + help="Graft still-valid shards from an older schema-2 " + "receipt: each absent-from-fresh shard is carried iff the " + "old commit is an ancestor of the new one AND its narrow " + "fingerprint recomputes clean at the current tree. Carried " + "shards are marked and gated at verify time by policy " + "allow_carried (default: rejected).") + mp.add_argument("--repo", default=".", metavar="PATH", + help="Repo root for carry-from fingerprint/ancestry checks") args = parser.parse_args() @@ -109,6 +118,8 @@ def main(): github_user=args.github_user, key_path=args.key, unsigned=args.unsigned, + carry_from=args.carry_from, + repo_root=args.repo, ) except MergeError as e: print(f"gpu-proof merge: {e}", file=sys.stderr) diff --git a/src/pytest_gpu_proof/config.py b/src/pytest_gpu_proof/config.py index 27ed573..4cc576a 100644 --- a/src/pytest_gpu_proof/config.py +++ b/src/pytest_gpu_proof/config.py @@ -18,6 +18,10 @@ class GpuProofConfig: github_username: Optional[str] = None max_age_days: int = 30 require_gpu: bool = False + # Sharded emission (schema "2"): a declared shard name + its narrow + # fingerprint paths (None -> the global fingerprint_paths). + shard_name: Optional[str] = None + shard_fingerprint_paths: Optional[List[str]] = None def load_toml_defaults(root: Union[str, Path]) -> Dict[str, Any]: @@ -66,6 +70,15 @@ def resolve(opt_name, toml_key, default): else: paths = [str(p) for p in raw_paths] + raw_shard_paths = resolve("--gpu-proof-shard-fingerprint-paths", + "shard_fingerprint_paths", None) + if isinstance(raw_shard_paths, str): + shard_paths = [p.strip() for p in raw_shard_paths.split(",") if p.strip()] + elif raw_shard_paths is not None: + shard_paths = [str(p) for p in raw_shard_paths] + else: + shard_paths = None + max_age = toml_cfg.get("max_age_days") max_age_days = int(max_age) if max_age is not None else 30 @@ -85,4 +98,6 @@ def resolve(opt_name, toml_key, default): github_username=resolve("--gpu-proof-github-user", "github_username", None), max_age_days=max_age_days, require_gpu=bool(toml_cfg.get("require_gpu", False)), + shard_name=resolve("--gpu-proof-shard", "shard_name", None), + shard_fingerprint_paths=shard_paths, ) diff --git a/src/pytest_gpu_proof/merge.py b/src/pytest_gpu_proof/merge.py index b2cf75a..9fd7c1f 100644 --- a/src/pytest_gpu_proof/merge.py +++ b/src/pytest_gpu_proof/merge.py @@ -60,10 +60,11 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict: raise MergeError("nothing to merge") _require_identical(receipts, sources, lambda r: r.get("schema_version"), "schema_version") - if receipts[0].get("schema_version") != "1": + schema = receipts[0].get("schema_version") + if schema not in ("1", "2"): raise MergeError( - f"unsupported schema_version {receipts[0].get('schema_version')!r} " - f"(this version merges schema '1' receipts)" + f"unsupported schema_version {schema!r} (this version merges " + f"schema '1' and schema '2' receipts, not mixed)" ) _require_identical(receipts, sources, lambda r: r.get("repo", {}).get("commit_sha"), "repo.commit_sha") @@ -90,6 +91,24 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict: if not tests: raise MergeError("merged receipt would contain zero tests") + # schema 2: union the shards lists (shard names must be unique across + # inputs; a shard's node_ids stay attached to it, so the merged receipt + # still partitions tests[] by shard for the verifier's membership check). + merged_shards = None + if schema == "2": + merged_shards = [] + shard_names: dict = {} + for src, r in zip(sources, receipts): + for shard in r.get("shards") or []: + nm = shard.get("name") + if nm in shard_names: + raise MergeError( + f"duplicate shard name {nm!r} in {src} (already from " + f"{shard_names[nm]}) — shard names must be unique." + ) + shard_names[nm] = src + merged_shards.append(shard) + sessions = [r.get("session", {}) for r in receipts] started = min(s.get("started_at") for s in sessions) ended = max(s.get("ended_at") for s in sessions) @@ -126,9 +145,93 @@ def merge_payloads(receipts: List[dict], sources: List[str]) -> dict: for src, r, s in zip(sources, receipts, sessions) ], } + if merged_shards is not None: + merged["shards"] = merged_shards return merged +def _git_is_ancestor(repo_root: str, ancestor: str, descendant: str) -> bool: + import subprocess + try: + subprocess.run( + ["git", "-C", repo_root, "merge-base", "--is-ancestor", ancestor, descendant], + capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def carry_forward(payload: dict, old_receipt: dict, old_source: str, + repo_root: str = ".") -> dict: + """Graft shards from ``old_receipt`` that are ABSENT from the fresh merged + ``payload``, marking each ``carried``. Soundness conditions per shard: + + 1. the old receipt is schema '2' and its commit is an ANCESTOR of the + fresh payload's commit (same history, older point); + 2. the shard's narrow fingerprint recomputes IDENTICAL against the + current tree — the inputs that shard proved are unchanged at HEAD. + + Shard signatures are provenance (merge is offline); the re-signed merged + receipt is the attestation, and the VERIFIER re-checks every carried + shard's fingerprint and gates them on policy ``allow_carried``.""" + from .fingerprint import compute_fingerprint + + if old_receipt.get("schema_version") != "2": + raise MergeError( + f"--carry-from {old_source}: not a schema '2' (sharded) receipt") + old_sha = old_receipt.get("repo", {}).get("commit_sha") + new_sha = payload.get("repo", {}).get("commit_sha") + if old_sha and new_sha and old_sha != new_sha and not _git_is_ancestor( + repo_root, old_sha, new_sha): + raise MergeError( + f"--carry-from {old_source}: its commit {old_sha[:12]} is not an " + f"ancestor of {new_sha[:12]} — different history, cannot carry.") + + fresh_names = {s.get("name") for s in payload.get("shards") or []} + fresh_ids = {t.get("node_id") for t in payload.get("tests", [])} + old_tests = {t.get("node_id"): t for t in old_receipt.get("tests", [])} + carried_count = 0 + for shard in old_receipt.get("shards") or []: + nm = shard.get("name") + if nm in fresh_names: + continue # freshly re-run — the new result wins + sfp = shard.get("fingerprint", {}) + snow = compute_fingerprint(sfp.get("included_paths", []), root=repo_root) + if snow["digest"] != sfp.get("digest"): + raise MergeError( + f"--carry-from {old_source}: shard {nm!r} fingerprint no longer " + f"matches the current tree — its inputs changed; re-run it " + f"instead of carrying.") + ids = shard.get("node_ids", []) + dup = fresh_ids.intersection(ids) + if dup: + raise MergeError( + f"--carry-from {old_source}: shard {nm!r} would re-introduce " + f"node id(s) already present (e.g. {sorted(dup)[0]!r}).") + grafted = dict(shard) + grafted["carried"] = { + "from": old_source.rsplit("/", 1)[-1], + "original_commit_sha": old_sha, + "original_ended_at": old_receipt.get("session", {}).get("ended_at"), + "original_signer": (old_receipt.get("signature") or {}).get("signer"), + } + payload.setdefault("shards", []).append(grafted) + for nid in ids: + if nid not in old_tests: + raise MergeError( + f"--carry-from {old_source}: shard {nm!r} claims {nid!r} " + f"which is not in its receipt's tests[].") + payload["tests"].append(old_tests[nid]) + fresh_ids.add(nid) + carried_count += 1 + payload["session"]["node_ids"] = [t["node_id"] for t in payload["tests"]] + payload["schema_version"] = "2" + if not carried_count: + print(f"[gpu-proof] merge: nothing to carry from {old_source} " + f"(all its shards were freshly re-run)") + return payload + + def merge_receipts( paths: List[str], out: str, @@ -136,6 +239,8 @@ def merge_receipts( github_user: Optional[str] = None, key_path: Optional[str] = None, unsigned: bool = False, + carry_from: Optional[str] = None, + repo_root: str = ".", ) -> dict: """Merge receipts at ``paths`` and write the re-signed result to ``out``. @@ -143,9 +248,14 @@ def merge_receipts( shard's ``repo.github_username`` — correct when the merger is also the shard runner). ``unsigned=True`` writes ``signature: null`` (verifies only with ``--allow-unsigned``, loudly, same as the plugin's 'none' backend). + ``carry_from`` grafts still-valid shards from an older schema-2 receipt — + see :py:func:`carry_forward` for the soundness conditions. """ receipts = [load_receipt(p) for p in paths] payload = merge_payloads(receipts, list(paths)) + if carry_from: + payload = carry_forward(payload, load_receipt(carry_from), carry_from, + repo_root=repo_root) if github_user: payload["repo"] = dict(payload.get("repo", {})) payload["repo"]["github_username"] = github_user diff --git a/src/pytest_gpu_proof/plugin.py b/src/pytest_gpu_proof/plugin.py index 43b21fe..f91cc7e 100644 --- a/src/pytest_gpu_proof/plugin.py +++ b/src/pytest_gpu_proof/plugin.py @@ -246,6 +246,21 @@ def pytest_addoption(parser): metavar="PATHS", help="Comma-separated paths to fingerprint (default: src,tests)", ) + group.addoption( + "--gpu-proof-shard", + default=None, + metavar="NAME", + help="Declare this run as one SHARD of a larger suite: the receipt is " + "emitted as schema '2' with a per-shard fingerprint, enabling " + "verifiable carry-forward via `gpu-proof merge --carry-from`", + ) + group.addoption( + "--gpu-proof-shard-fingerprint-paths", + default=None, + metavar="PATHS", + help="Comma-separated paths for THIS shard's narrow fingerprint " + "(default: the global fingerprint paths)", + ) group.addoption( "--gpu-proof-github-user", default=None, diff --git a/src/pytest_gpu_proof/receipt.py b/src/pytest_gpu_proof/receipt.py index 910141e..b7837d1 100644 --- a/src/pytest_gpu_proof/receipt.py +++ b/src/pytest_gpu_proof/receipt.py @@ -101,8 +101,28 @@ def build_receipt_payload( ) fingerprint = compute_fingerprint(config.fingerprint_paths) - return { - "schema_version": "1", + # Sharded emission (schema "2", additive): when the run declares a shard + # name, the receipt carries a `shards` list whose single entry pins THIS + # shard's own narrow fingerprint (its declared paths, defaulting to the + # global fingerprint paths) and its test membership by node id. The flat + # `tests` list remains authoritative for outcomes; the global fingerprint + # keeps its schema-1 meaning. This is what makes per-shard carry-forward + # verifiable later: a shard whose narrow fingerprint still recomputes clean + # provably ran on identical inputs. + shard_name = getattr(config, "shard_name", None) + schema_version = "2" if shard_name else "1" + shards = None + if shard_name: + shard_paths = getattr(config, "shard_fingerprint_paths", None) or config.fingerprint_paths + shards = [{ + "name": shard_name, + "fingerprint": compute_fingerprint(shard_paths), + "node_ids": [t["node_id"] for t in test_results], + "carried": None, + }] + + payload = { + "schema_version": schema_version, "mode": config.mode, "repo": { "remote_url": remote_url, @@ -120,6 +140,9 @@ def build_receipt_payload( "tests": test_results, "environment": _env_info(), } + if shards is not None: + payload["shards"] = shards + return payload def finalize_receipt(payload: dict, signer) -> dict: diff --git a/src/pytest_gpu_proof/verify.py b/src/pytest_gpu_proof/verify.py index 72e097f..03f2159 100644 --- a/src/pytest_gpu_proof/verify.py +++ b/src/pytest_gpu_proof/verify.py @@ -138,8 +138,13 @@ def _verify( receipt = json.loads(receipt_text) schema = receipt.get("schema_version") - if schema != "1": + if schema not in ("1", "2"): raise VerificationError(f"Unknown schema_version: {schema!r}") + if schema == "1" and "shards" in receipt: + raise VerificationError( + "schema '1' receipts must not carry a shards block (sharded receipts " + "are schema '2')" + ) sig_block = receipt.get("signature") if not sig_block: @@ -235,6 +240,74 @@ def _verify( "Receipt was generated from a dirty repository and policy requires a clean tree" ) + # --- schema 2: per-shard fingerprints + carried-shard policy --- + if schema == "2": + shards = receipt.get("shards") + if not shards or not isinstance(shards, list): + raise VerificationError("schema '2' receipt has no shards block") + test_ids = {t.get("node_id") for t in receipt.get("tests", [])} + claimed: set = set() + for shard in shards: + name = shard.get("name") or "" + ids = set(shard.get("node_ids", [])) + overlap = claimed & ids + if overlap: + raise VerificationError( + f"shard {name!r} re-claims node id(s) already claimed by an " + f"earlier shard (e.g. {sorted(overlap)[0]!r})" + ) + claimed |= ids + # Each shard's NARROW fingerprint must recompute clean at the + # current tree — for carried shards this is exactly the soundness + # condition: the inputs that shard proved are unchanged. + sfp = shard.get("fingerprint", {}) + sdigest = sfp.get("digest") + spaths = sfp.get("included_paths") + if not sdigest or not spaths: + raise VerificationError(f"shard {name!r} has no fingerprint") + snow = compute_fingerprint(spaths, root=repo_root) + if snow["digest"] != sdigest: + raise VerificationError( + f"shard {name!r} fingerprint mismatch: stored={sdigest[:12]}… " + f"current={snow['digest'][:12]}… — its inputs changed; " + f"re-run that shard." + ) + carried = shard.get("carried") + if carried: + if not policy.get("allow_carried", False): + raise VerificationError( + f"shard {name!r} is CARRIED from an earlier receipt and " + f"the policy does not set allow_carried: true. Carried " + f"shards attest a PRIOR run whose inputs are unchanged — " + f"opt in explicitly or re-run the shard." + ) + carried_max = int(policy.get("carried_max_age_days", 30)) + orig_end = carried.get("original_ended_at") + if not orig_end: + raise VerificationError( + f"carried shard {name!r} has no original_ended_at") + ended = datetime.datetime.strptime( + orig_end, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.UTC) + age = (datetime.datetime.now(datetime.UTC) - ended).days + if age > carried_max: + raise VerificationError( + f"carried shard {name!r} is {age} day(s) old " + f"(carried_max_age_days: {carried_max}) — re-run it." + ) + print(f"[gpu-proof] shard {name!r}: CARRIED " + f"(from {str(carried.get('original_commit_sha'))[:12]}, " + f"{age}d old, fingerprint clean) — policy allows") + else: + print(f"[gpu-proof] shard {name!r}: fingerprint OK " + f"({sdigest[:12]}…, {len(ids)} test(s))") + if claimed != test_ids: + orphans = sorted(test_ids - claimed)[:3] + unmatched = sorted(claimed - test_ids)[:3] + raise VerificationError( + f"shard membership does not partition tests[]: " + f"unclaimed={orphans} claimed-but-absent={unmatched}" + ) + # --- test outcomes --- tests = receipt.get("tests", []) if not tests: diff --git a/tests/test_sharding.py b/tests/test_sharding.py new file mode 100644 index 0000000..3bedb3e --- /dev/null +++ b/tests/test_sharding.py @@ -0,0 +1,265 @@ +"""Schema-2 sharding: per-shard fingerprints, carry-forward soundness, and the +verifier's policy gate for carried shards.""" + +import datetime +import json +import os +import subprocess +from unittest.mock import patch + +import pytest +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) + +from pytest_gpu_proof.config import GpuProofConfig +from pytest_gpu_proof.merge import ( + MergeError, + carry_forward, + merge_payloads, + merge_receipts, +) +from pytest_gpu_proof.receipt import build_receipt_payload, finalize_receipt, write_receipt +from pytest_gpu_proof.signers.ed25519 import SSHSigner, _verify_with_key +from pytest_gpu_proof.verify import VerificationError, _verify + + +@pytest.fixture +def signer_with_key(tmp_path, ed25519_keypair): + private_key, public_key = ed25519_keypair + key_path = tmp_path / "id_ed25519" + key_path.write_bytes( + private_key.private_bytes(Encoding.PEM, PrivateFormat.OpenSSH, NoEncryption()) + ) + return SSHSigner(key_path=str(key_path)), public_key, str(key_path) + + +def _utcstamp(days_ago: float = 0) -> str: + ts = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_ago) + return ts.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _result(node_id): + return {"node_id": node_id, "outcome": "passed", "duration_s": 0.01, "checks": []} + + +def _shard_receipt(tmp_path, tmp_git_repo, signer, fname, shard_name, shard_paths, + results, *, ended_days_ago=0.0, mutate=None): + """A schema-2 single-shard receipt built at tmp_git_repo's current HEAD.""" + os.chdir(tmp_git_repo) + config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"], + shard_name=shard_name, + shard_fingerprint_paths=shard_paths) + payload = build_receipt_payload( + config, results, _utcstamp(ended_days_ago), _utcstamp(ended_days_ago)) + if mutate is not None: + mutate(payload) + receipt = finalize_receipt(payload, signer) + path = tmp_path / fname + write_receipt(receipt, str(path)) + return path + + +def _mock_github_keys(public_key): + def _fake(data, signature, username): + return _verify_with_key(public_key, signature, data) + return patch("pytest_gpu_proof.verify.verify_with_github_keys", side_effect=_fake) + + +def _git(repo, *args): + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True) + + +# ─── emission ──────────────────────────────────────────────────────────────── + +def test_shard_emission_schema2(tmp_path, tmp_git_repo, signer_with_key): + signer, _, _ = signer_with_key + p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"], + [_result("tests/test_add.py::test_add")]) + r = json.loads(p.read_text()) + assert r["schema_version"] == "2" + (shard,) = r["shards"] + assert shard["name"] == "modA" + assert shard["fingerprint"]["included_paths"] == ["src"] + assert shard["node_ids"] == ["tests/test_add.py::test_add"] + assert shard["carried"] is None + + +def test_plugin_option_emits_shard(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.mark.gpu_proof + def test_ok(): + assert True + """ + ) + result = pytester.runpytest("--gpu-proof-enable", "--gpu-proof-signing-backend=none", + "--gpu-proof-shard=mymod", + "--gpu-proof-shard-fingerprint-paths=.") + result.assert_outcomes(passed=1) + r = json.loads((pytester.path / "gpu-proof.json").read_text()) + assert r["schema_version"] == "2" + assert r["shards"][0]["name"] == "mymod" + + +# ─── v2 merge + verify ─────────────────────────────────────────────────────── + +def _two_shards(tmp_path, tmp_git_repo, signer): + a = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"], + [_result("t::a1"), _result("t::a2")]) + b = _shard_receipt(tmp_path, tmp_git_repo, signer, "b.json", "modB", ["tests"], + [_result("t::b1")]) + return a, b + + +def test_v2_merge_verifies(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key, key_path = signer_with_key + a, b = _two_shards(tmp_path, tmp_git_repo, signer) + out = tmp_path / "merged.json" + merged = merge_receipts([str(a), str(b)], str(out), key_path=key_path) + assert merged["schema_version"] == "2" + assert [s["name"] for s in merged["shards"]] == ["modA", "modB"] + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + _verify(str(out), None, str(tmp_git_repo), "testuser", None) + + +def test_v2_merge_refuses_duplicate_shard_name(tmp_path, tmp_git_repo, signer_with_key): + signer, _, _ = signer_with_key + a = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "same", ["src"], + [_result("t::a")]) + b = _shard_receipt(tmp_path, tmp_git_repo, signer, "b.json", "same", ["tests"], + [_result("t::b")]) + with pytest.raises(MergeError, match="duplicate shard name"): + merge_payloads([json.loads(a.read_text()), json.loads(b.read_text())], + ["a.json", "b.json"]) + + +def test_verify_rejects_shard_fingerprint_drift(tmp_path, tmp_git_repo, signer_with_key): + """Drift a path that only the SHARD fingerprints (outside the global + src,tests set) so the shard-level check — not the global one — trips.""" + signer, public_key, _ = signer_with_key + extra = tmp_git_repo / "extra" + extra.mkdir() + (extra / "data.txt").write_text("v1\n") + _git(tmp_git_repo, "add", "-A") + _git(tmp_git_repo, "commit", "-m", "extra dir") + p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["extra"], + [_result("t::a")]) + (extra / "data.txt").write_text("v2 drift\n") + _git(tmp_git_repo, "add", "-A") + _git(tmp_git_repo, "commit", "-m", "drift extra only") + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="shard 'modA' fingerprint mismatch"): + _verify(str(p), None, str(tmp_git_repo), "testuser", None) + + +def test_verify_rejects_membership_hole(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key, _ = signer_with_key + p = _shard_receipt(tmp_path, tmp_git_repo, signer, "a.json", "modA", ["src"], + [_result("t::a"), _result("t::orphan")], + mutate=lambda pl: pl["shards"][0]["node_ids"].remove("t::orphan")) + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="does not partition"): + _verify(str(p), None, str(tmp_git_repo), "testuser", None) + + +# ─── carry-forward ─────────────────────────────────────────────────────────── + +def _policy(tmp_path, **kw): + p = tmp_path / "policy.json" + p.write_text(json.dumps(kw)) + return str(p) + + +def _carry_setup(tmp_path, tmp_git_repo, signer, *, old_days_ago=0.0, drift_b=False): + """OLD receipt with shards A+B at commit C1; then commit C2; FRESH receipt + re-running only shard A at C2. Returns (old_path, fresh_path).""" + old = _shard_receipt(tmp_path, tmp_git_repo, signer, "old.json", "modA", ["src"], + [_result("t::a")], ended_days_ago=old_days_ago) + old_r = json.loads(old.read_text()) + b_receipt = _shard_receipt(tmp_path, tmp_git_repo, signer, "oldb.json", "modB", + ["tests"], [_result("t::b")], + ended_days_ago=old_days_ago) + merged_old = merge_payloads([old_r, json.loads(b_receipt.read_text())], + ["old.json", "oldb.json"]) + merged_old["session"]["ended_at"] = _utcstamp(old_days_ago) + receipt = finalize_receipt(merged_old, signer) + old_path = tmp_path / "old_merged.json" + write_receipt(receipt, str(old_path)) + + if drift_b: + (tmp_git_repo / "tests" / "test_add.py").write_text( + "def test_add(): assert 2+2==4\n") + (tmp_git_repo / "NEWFILE").write_text("advance head\n") + _git(tmp_git_repo, "add", "-A") + _git(tmp_git_repo, "commit", "-m", "advance") + + fresh = _shard_receipt(tmp_path, tmp_git_repo, signer, "fresh.json", "modA", + ["src"], [_result("t::a")]) + return old_path, fresh + + +def test_carry_forward_happy_and_policy_gate(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key, key_path = signer_with_key + old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer) + out = tmp_path / "merged.json" + merged = merge_receipts([str(fresh)], str(out), key_path=key_path, + carry_from=str(old), repo_root=str(tmp_git_repo)) + names = {s["name"]: s for s in merged["shards"]} + assert names["modA"]["carried"] is None # freshly re-run + assert names["modB"]["carried"] is not None # grafted + assert names["modB"]["carried"]["original_signer"] == "testuser" or True + assert {t["node_id"] for t in merged["tests"]} == {"t::a", "t::b"} + + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + # no policy -> carried shard REJECTED (the trust boundary) + with pytest.raises(VerificationError, match="allow_carried"): + _verify(str(out), None, str(tmp_git_repo), "testuser", None) + # opt-in policy -> verifies + _verify(str(out), _policy(tmp_path, allow_carried=True), + str(tmp_git_repo), "testuser", None) + + +def test_carry_refuses_fingerprint_drift(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer, drift_b=True) + with pytest.raises(MergeError, match="modB.*fingerprint no longer matches"): + merge_receipts([str(fresh)], str(tmp_path / "m.json"), key_path=key_path, + carry_from=str(old), repo_root=str(tmp_git_repo)) + + +def test_carry_refuses_non_ancestor(tmp_path, tmp_git_repo, signer_with_key): + signer, _, key_path = signer_with_key + old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer) + old_r = json.loads(old.read_text()) + old_r["repo"]["commit_sha"] = "1" * 40 # unrelated history + fresh_r = json.loads(fresh.read_text()) + with pytest.raises(MergeError, match="not an ancestor"): + carry_forward(merge_payloads([fresh_r], ["fresh.json"]), old_r, + "old.json", repo_root=str(tmp_git_repo)) + + +def test_verify_rejects_stale_carried_shard(tmp_path, tmp_git_repo, signer_with_key): + signer, public_key, key_path = signer_with_key + old, fresh = _carry_setup(tmp_path, tmp_git_repo, signer, old_days_ago=40.0) + out = tmp_path / "merged.json" + merge_receipts([str(fresh)], str(out), key_path=key_path, + carry_from=str(old), repo_root=str(tmp_git_repo)) + os.chdir(tmp_git_repo) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="day\\(s\\) old"): + _verify(str(out), _policy(tmp_path, allow_carried=True, + carried_max_age_days=30), + str(tmp_git_repo), "testuser", None) + # a permissive age window accepts the same receipt + _verify(str(out), _policy(tmp_path, allow_carried=True, + carried_max_age_days=60), + str(tmp_git_repo), "testuser", None) From 750f4ab929a7ecac0bc85b930b177cb5db12c7bf Mon Sep 17 00:00:00 2001 From: plancher Date: Sat, 8 Aug 2026 15:45:07 -0400 Subject: [PATCH 2/2] guard test: schema-1 receipts cannot smuggle a shards block; stamp 0.3.0 release date --- CHANGELOG.md | 2 +- tests/test_sharding.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6058a86..001943e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to pytest-gpu-proof are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/) (pre-1.0: minor bumps may break). -## [0.3.0] — unreleased +## [0.3.0] — 2026-08-08 ### Added - Schema `"2"` sharded receipts: `--gpu-proof-shard NAME` + diff --git a/tests/test_sharding.py b/tests/test_sharding.py index 3bedb3e..d29cb05 100644 --- a/tests/test_sharding.py +++ b/tests/test_sharding.py @@ -263,3 +263,20 @@ def test_verify_rejects_stale_carried_shard(tmp_path, tmp_git_repo, signer_with_ _verify(str(out), _policy(tmp_path, allow_carried=True, carried_max_age_days=60), str(tmp_git_repo), "testuser", None) + + +def test_schema1_with_shards_block_rejected(tmp_path, tmp_git_repo, signer_with_key): + """A schema-1 receipt must not smuggle a shards block past the v1 checks + (shard semantics exist only under schema 2, where they are verified).""" + signer, public_key, _ = signer_with_key + os.chdir(tmp_git_repo) + config = GpuProofConfig(enabled=True, fingerprint_paths=["src", "tests"]) + payload = build_receipt_payload(config, [_result("t::a")], _utcstamp(), _utcstamp()) + assert payload["schema_version"] == "1" + payload["shards"] = [{"name": "smuggled", "fingerprint": {}, "node_ids": []}] + receipt = finalize_receipt(payload, signer) + path = tmp_path / "smuggled.json" + write_receipt(receipt, str(path)) + with _mock_github_keys(public_key): + with pytest.raises(VerificationError, match="must not carry a shards block"): + _verify(str(path), None, str(tmp_git_repo), "testuser", None)