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.
- pytest_gpu_proof/__init__.py +1 -0
- pytest_gpu_proof/__main__.py +3 -0
- pytest_gpu_proof/cli.py +95 -0
- pytest_gpu_proof/compare.py +49 -0
- pytest_gpu_proof/config.py +82 -0
- pytest_gpu_proof/fingerprint.py +38 -0
- pytest_gpu_proof/gitutils.py +112 -0
- pytest_gpu_proof/plugin.py +331 -0
- pytest_gpu_proof/receipt.py +143 -0
- pytest_gpu_proof/signers/__init__.py +0 -0
- pytest_gpu_proof/signers/base.py +20 -0
- pytest_gpu_proof/signers/ed25519.py +178 -0
- pytest_gpu_proof/verify.py +339 -0
- pytest_gpu_proof-0.1.0.dist-info/METADATA +400 -0
- pytest_gpu_proof-0.1.0.dist-info/RECORD +18 -0
- pytest_gpu_proof-0.1.0.dist-info/WHEEL +4 -0
- pytest_gpu_proof-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_gpu_proof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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.")
|