pytest-gpu-proof 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ """
2
+ Standalone receipt verifier.
3
+
4
+ Checks:
5
+ 1. Signature — fetches signer's public keys from github.com/{username}.keys
6
+ (unsigned receipts are rejected unless allow_unsigned is set)
7
+ 2. Fingerprint — recomputes and compares digest
8
+ 3. Commit SHA — compares against current repo state
9
+ 4. Test outcomes — all tests in receipt must have passed; skipped marked
10
+ tests are rejected unless allow_skipped is set, or an expected-skips
11
+ baseline is given and the receipt's skip set matches it EXACTLY
12
+ 5. GPU info — optionally require environment.gpu_info (require_gpu)
13
+ 6. Freshness — receipt must not be older than max_age_days
14
+ 7. Dirty policy — reject dirty-tree receipts if policy requires clean
15
+ """
16
+
17
+ import base64
18
+ import datetime
19
+ import json
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from .signers.ed25519 import verify_with_github_keys
26
+ from .signers.base import VerifierError as _VerifierError
27
+
28
+
29
+ class VerificationError(Exception):
30
+ pass
31
+
32
+
33
+ def _load_policy(policy_path: Optional[str]) -> dict:
34
+ if not policy_path:
35
+ return {}
36
+ text = Path(policy_path).read_text()
37
+ if policy_path.endswith(".json"):
38
+ return json.loads(text) or {}
39
+ try:
40
+ import yaml # type: ignore
41
+ except ImportError:
42
+ raise VerificationError(
43
+ f"Policy file {policy_path!r} is YAML but PyYAML is not installed. "
44
+ "Install it with 'pip install pyyaml', or use a .json policy file."
45
+ )
46
+ return yaml.safe_load(text) or {}
47
+
48
+
49
+ def _load_expected_skips(path: str) -> set:
50
+ """Baseline file: one node ID per line; blank lines and '#' comments ignored."""
51
+ lines = Path(path).read_text().splitlines()
52
+ entries = set()
53
+ for line in lines:
54
+ line = line.strip()
55
+ if line and not line.startswith("#"):
56
+ entries.add(line)
57
+ return entries
58
+
59
+
60
+ def _receipt_payload_without_sig(receipt: dict) -> bytes:
61
+ from .receipt import canonicalize
62
+ payload = {k: v for k, v in receipt.items() if k != "signature"}
63
+ return canonicalize(payload)
64
+
65
+
66
+ def _git(repo_root: str, *args: str) -> Optional[str]:
67
+ try:
68
+ result = subprocess.run(
69
+ ["git", "-C", repo_root, *args],
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ )
74
+ return result.stdout.strip() or None
75
+ except (subprocess.CalledProcessError, FileNotFoundError):
76
+ return None
77
+
78
+
79
+ def _is_ancestor(repo_root: str, ancestor_sha: str, descendant_sha: str) -> bool:
80
+ try:
81
+ subprocess.run(
82
+ ["git", "-C", repo_root, "merge-base", "--is-ancestor", ancestor_sha, descendant_sha],
83
+ capture_output=True,
84
+ text=True,
85
+ check=True,
86
+ )
87
+ return True
88
+ except (subprocess.CalledProcessError, FileNotFoundError):
89
+ return False
90
+
91
+
92
+ def verify_receipt(
93
+ receipt_path: str,
94
+ policy_path: Optional[str] = None,
95
+ repo_root: str = ".",
96
+ github_user_override: Optional[str] = None,
97
+ max_age_days: Optional[int] = None,
98
+ allow_unsigned: bool = False,
99
+ allow_skipped: bool = False,
100
+ require_gpu: Optional[bool] = None,
101
+ expected_skips_path: Optional[str] = None,
102
+ ) -> bool:
103
+ try:
104
+ _verify(
105
+ receipt_path,
106
+ policy_path,
107
+ repo_root,
108
+ github_user_override,
109
+ max_age_days,
110
+ allow_unsigned=allow_unsigned,
111
+ allow_skipped=allow_skipped,
112
+ require_gpu=require_gpu,
113
+ expected_skips_path=expected_skips_path,
114
+ )
115
+ return True
116
+ except VerificationError as e:
117
+ print(f"[gpu-proof] FAIL: {e}", file=sys.stderr)
118
+ return False
119
+
120
+
121
+ def _verify(
122
+ receipt_path: str,
123
+ policy_path: Optional[str],
124
+ repo_root: str,
125
+ github_user_override: Optional[str],
126
+ max_age_days_override: Optional[int],
127
+ allow_unsigned: bool = False,
128
+ allow_skipped: bool = False,
129
+ expected_skips_path: Optional[str] = None,
130
+ require_gpu: Optional[bool] = None,
131
+ ):
132
+ from .config import load_toml_defaults
133
+
134
+ toml_cfg = load_toml_defaults(repo_root)
135
+
136
+ # --- load receipt ---
137
+ receipt_text = Path(receipt_path).read_text()
138
+ receipt = json.loads(receipt_text)
139
+
140
+ schema = receipt.get("schema_version")
141
+ if schema != "1":
142
+ raise VerificationError(f"Unknown schema_version: {schema!r}")
143
+
144
+ sig_block = receipt.get("signature")
145
+ if not sig_block:
146
+ if not allow_unsigned:
147
+ raise VerificationError(
148
+ "Receipt is UNSIGNED (no signature block). Unsigned receipts prove "
149
+ "nothing about who ran the tests. Pass --allow-unsigned only if you "
150
+ "explicitly accept that."
151
+ )
152
+ print(
153
+ "[gpu-proof] WARNING: receipt is UNSIGNED and --allow-unsigned was passed.\n"
154
+ "[gpu-proof] WARNING: signature verification SKIPPED — this receipt proves\n"
155
+ "[gpu-proof] WARNING: nothing about who ran the tests or on what machine."
156
+ )
157
+ else:
158
+ sig_b64 = sig_block.get("value", "")
159
+ if not sig_b64:
160
+ raise VerificationError("Signature value is missing")
161
+
162
+ try:
163
+ signature = base64.b64decode(sig_b64)
164
+ except Exception:
165
+ raise VerificationError("Signature value is not valid base64")
166
+
167
+ # --- verify signature via GitHub public keys ---
168
+ github_username = (
169
+ github_user_override
170
+ or sig_block.get("signer")
171
+ or receipt.get("repo", {}).get("github_username")
172
+ )
173
+ if not github_username:
174
+ raise VerificationError(
175
+ "Cannot determine GitHub username. Pass --github-user=USERNAME."
176
+ )
177
+
178
+ payload_bytes = _receipt_payload_without_sig(receipt)
179
+
180
+ print(f"[gpu-proof] Fetching public keys for @{github_username} …")
181
+ try:
182
+ ok = verify_with_github_keys(payload_bytes, signature, github_username)
183
+ except _VerifierError as e:
184
+ raise VerificationError(str(e))
185
+
186
+ if not ok:
187
+ raise VerificationError(
188
+ f"Signature does not match any SSH key registered by @{github_username} on GitHub"
189
+ )
190
+ print(f"[gpu-proof] Signature valid (signer: @{github_username})")
191
+
192
+ # --- recompute fingerprint ---
193
+ repo = receipt.get("repo", {})
194
+ stored_fp = receipt.get("fingerprint", {})
195
+ fp_paths = stored_fp.get("included_paths", ["src", "tests"])
196
+
197
+ from .fingerprint import compute_fingerprint # noqa: PLC0415 (local import ok here)
198
+
199
+ current_fp = compute_fingerprint(fp_paths, root=repo_root)
200
+ stored_digest = stored_fp.get("digest")
201
+ if not stored_digest:
202
+ raise VerificationError("Receipt fingerprint block is missing its digest")
203
+ if current_fp["digest"] != stored_digest:
204
+ raise VerificationError(
205
+ f"Fingerprint mismatch: stored={stored_digest[:12]}… "
206
+ f"current={current_fp['digest'][:12]}…\n"
207
+ "The code under src/ or tests/ has changed since the receipt was generated."
208
+ )
209
+ print(f"[gpu-proof] Fingerprint OK ({current_fp['digest'][:12]}…)")
210
+
211
+ # --- commit SHA check ---
212
+ current_sha = _git(repo_root, "rev-parse", "HEAD")
213
+ stored_sha = repo.get("commit_sha")
214
+ if (
215
+ current_sha
216
+ and stored_sha
217
+ and current_sha != stored_sha
218
+ and not _is_ancestor(repo_root, stored_sha, current_sha)
219
+ ):
220
+ raise VerificationError(
221
+ f"Commit SHA mismatch: receipt={stored_sha[:12]} current={current_sha[:12]}"
222
+ )
223
+ if current_sha and stored_sha and current_sha != stored_sha:
224
+ print(
225
+ f"[gpu-proof] Commit SHA OK ({stored_sha[:12]}… ancestor of {current_sha[:12]}…)"
226
+ )
227
+ elif stored_sha:
228
+ print(f"[gpu-proof] Commit SHA OK ({stored_sha[:12]}…)")
229
+
230
+ # --- dirty repo policy ---
231
+ policy = _load_policy(policy_path)
232
+ allow_dirty = policy.get("allow_dirty", True)
233
+ if repo.get("dirty") and not allow_dirty:
234
+ raise VerificationError(
235
+ "Receipt was generated from a dirty repository and policy requires a clean tree"
236
+ )
237
+
238
+ # --- test outcomes ---
239
+ tests = receipt.get("tests", [])
240
+ if not tests:
241
+ raise VerificationError("Receipt contains no test results")
242
+
243
+ failed = [
244
+ t["node_id"] for t in tests if t.get("outcome") not in ("passed", "skipped")
245
+ ]
246
+ if failed:
247
+ raise VerificationError(
248
+ f"{len(failed)} test(s) did not pass: {', '.join(failed)}"
249
+ )
250
+ skipped = [t["node_id"] for t in tests if t.get("outcome") == "skipped"]
251
+
252
+ # Expected-skips baseline: the receipt's skip set must match EXACTLY.
253
+ # Stricter than --allow-skipped (which accepts ANY skips): new skips fail,
254
+ # and a baselined test that now runs flags the baseline as stale.
255
+ expected_skips = None
256
+ if expected_skips_path is not None:
257
+ if allow_skipped:
258
+ raise VerificationError(
259
+ "--expected-skips and --allow-skipped are mutually exclusive: "
260
+ "the baseline already defines exactly which skips are acceptable."
261
+ )
262
+ expected_skips = _load_expected_skips(expected_skips_path)
263
+ elif not allow_skipped and toml_cfg.get("expected_skips"):
264
+ expected_skips = set(toml_cfg["expected_skips"])
265
+
266
+ if expected_skips is not None:
267
+ got = set(skipped)
268
+ unexpected = sorted(got - expected_skips)
269
+ stale = sorted(expected_skips - got)
270
+ problems = []
271
+ if unexpected:
272
+ problems.append(
273
+ f"{len(unexpected)} skip(s) NOT in the baseline: {', '.join(unexpected)}"
274
+ )
275
+ if stale:
276
+ problems.append(
277
+ f"{len(stale)} baseline entr(y/ies) that did NOT skip (stale "
278
+ f"baseline — update it): {', '.join(stale)}"
279
+ )
280
+ if problems:
281
+ raise VerificationError(
282
+ "Skipped tests do not match the expected-skips baseline. "
283
+ + " | ".join(problems)
284
+ )
285
+ print(
286
+ f"[gpu-proof] Skipped tests match the expected baseline "
287
+ f"({len(expected_skips)} pinned skip(s))"
288
+ )
289
+ elif skipped and not allow_skipped:
290
+ raise VerificationError(
291
+ f"{len(skipped)} marked test(s) were skipped: {', '.join(skipped)}. "
292
+ "Skipped tests prove nothing; pass --allow-skipped to accept them, "
293
+ "or pin them with --expected-skips BASELINE_FILE."
294
+ )
295
+ elif skipped:
296
+ print(
297
+ f"[gpu-proof] WARNING: {len(skipped)} skipped test(s) accepted "
298
+ "(--allow-skipped)"
299
+ )
300
+ print(f"[gpu-proof] All {len(tests) - len(skipped)} executed test(s) passed")
301
+
302
+ # --- gpu_info policy (modest hardening, not proof) ---
303
+ if require_gpu is None:
304
+ require_gpu = bool(toml_cfg.get("require_gpu", False))
305
+ if require_gpu:
306
+ gpu_info = (receipt.get("environment") or {}).get("gpu_info")
307
+ if not gpu_info:
308
+ raise VerificationError(
309
+ "Receipt's environment.gpu_info is missing/null but the policy "
310
+ "requires GPU info (--require-gpu). The recording machine had no "
311
+ "visible GPU (or nvidia-smi failed)."
312
+ )
313
+ print(f"[gpu-proof] GPU info present ({gpu_info.get('name')})")
314
+
315
+ # --- freshness ---
316
+ if max_age_days_override is not None:
317
+ max_days = max_age_days_override
318
+ elif policy.get("max_age_days") is not None:
319
+ max_days = policy["max_age_days"]
320
+ elif toml_cfg.get("max_age_days") is not None:
321
+ max_days = toml_cfg["max_age_days"]
322
+ else:
323
+ max_days = 30
324
+ signed_at_str = receipt.get("session", {}).get("ended_at")
325
+ if signed_at_str:
326
+ try:
327
+ signed_at = datetime.datetime.strptime(
328
+ signed_at_str, "%Y-%m-%dT%H:%M:%SZ"
329
+ ).replace(tzinfo=datetime.UTC)
330
+ age = (datetime.datetime.now(datetime.UTC) - signed_at).days
331
+ if age > max_days:
332
+ raise VerificationError(
333
+ f"Receipt is {age} days old; policy allows max {max_days} days"
334
+ )
335
+ print(f"[gpu-proof] Freshness OK (age: {age} day(s), limit: {max_days})")
336
+ except ValueError:
337
+ pass
338
+
339
+ print(f"[gpu-proof] Receipt verified successfully.")