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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pytest_gpu_proof/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def main():
|
|
6
|
+
parser = argparse.ArgumentParser(
|
|
7
|
+
prog="gpu-proof",
|
|
8
|
+
description="pytest-gpu-proof: verify signed GPU test receipts",
|
|
9
|
+
)
|
|
10
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
11
|
+
|
|
12
|
+
# verify
|
|
13
|
+
vp = subparsers.add_parser(
|
|
14
|
+
"verify",
|
|
15
|
+
help="Verify a signed receipt against GitHub public keys",
|
|
16
|
+
)
|
|
17
|
+
vp.add_argument("--receipt", required=True, metavar="PATH", help="Path to gpu-proof.json")
|
|
18
|
+
vp.add_argument(
|
|
19
|
+
"--policy",
|
|
20
|
+
default=None,
|
|
21
|
+
metavar="PATH",
|
|
22
|
+
help="Path to policy YAML/JSON (optional)",
|
|
23
|
+
)
|
|
24
|
+
vp.add_argument(
|
|
25
|
+
"--repo",
|
|
26
|
+
default=".",
|
|
27
|
+
metavar="PATH",
|
|
28
|
+
help="Repository root for fingerprint recomputation (default: .)",
|
|
29
|
+
)
|
|
30
|
+
vp.add_argument(
|
|
31
|
+
"--github-user",
|
|
32
|
+
default=None,
|
|
33
|
+
metavar="USERNAME",
|
|
34
|
+
help="Override GitHub username (default: read from receipt)",
|
|
35
|
+
)
|
|
36
|
+
vp.add_argument(
|
|
37
|
+
"--max-age-days",
|
|
38
|
+
type=int,
|
|
39
|
+
default=None,
|
|
40
|
+
metavar="N",
|
|
41
|
+
help="Override max receipt age in days",
|
|
42
|
+
)
|
|
43
|
+
vp.add_argument(
|
|
44
|
+
"--allow-unsigned",
|
|
45
|
+
action="store_true",
|
|
46
|
+
default=False,
|
|
47
|
+
help="Accept UNSIGNED receipts (signature: null). Unsigned receipts prove "
|
|
48
|
+
"nothing about who ran the tests — use only if you accept that.",
|
|
49
|
+
)
|
|
50
|
+
vp.add_argument(
|
|
51
|
+
"--allow-skipped",
|
|
52
|
+
action="store_true",
|
|
53
|
+
default=False,
|
|
54
|
+
help="Accept receipts that contain skipped marked tests (default: reject)",
|
|
55
|
+
)
|
|
56
|
+
vp.add_argument(
|
|
57
|
+
"--expected-skips",
|
|
58
|
+
default=None,
|
|
59
|
+
metavar="PATH",
|
|
60
|
+
help="Baseline file of node IDs (one per line, '#' comments) that the "
|
|
61
|
+
"receipt's skipped tests must match EXACTLY — new skips fail, and a "
|
|
62
|
+
"baselined test that now runs flags the baseline as stale. Stricter "
|
|
63
|
+
"than (and mutually exclusive with) --allow-skipped. Can also be set "
|
|
64
|
+
"as an inline list via expected_skips in [tool.gpu_proof].",
|
|
65
|
+
)
|
|
66
|
+
vp.add_argument(
|
|
67
|
+
"--require-gpu",
|
|
68
|
+
action="store_true",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Fail verification if the receipt's environment.gpu_info is null/absent "
|
|
71
|
+
"(modest hardening, not proof of GPU execution). Can also be set via "
|
|
72
|
+
"require_gpu = true in [tool.gpu_proof].",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
if args.command == "verify":
|
|
78
|
+
from .verify import verify_receipt
|
|
79
|
+
|
|
80
|
+
ok = verify_receipt(
|
|
81
|
+
receipt_path=args.receipt,
|
|
82
|
+
policy_path=args.policy,
|
|
83
|
+
repo_root=args.repo,
|
|
84
|
+
github_user_override=args.github_user,
|
|
85
|
+
max_age_days=args.max_age_days,
|
|
86
|
+
allow_unsigned=args.allow_unsigned,
|
|
87
|
+
allow_skipped=args.allow_skipped,
|
|
88
|
+
require_gpu=args.require_gpu,
|
|
89
|
+
expected_skips_path=args.expected_skips,
|
|
90
|
+
)
|
|
91
|
+
sys.exit(0 if ok else 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Any, Callable, Optional, Tuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def default_compare(ref: Any, cand: Any) -> None:
|
|
5
|
+
"""numpy.allclose for float arrays, exact equality otherwise. Raises AssertionError on mismatch."""
|
|
6
|
+
try:
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
ref_arr = np.asarray(ref)
|
|
10
|
+
cand_arr = np.asarray(cand)
|
|
11
|
+
if ref_arr.dtype.kind in ("f", "c"):
|
|
12
|
+
if not np.allclose(ref_arr, cand_arr):
|
|
13
|
+
max_diff = float(np.max(np.abs(ref_arr - cand_arr)))
|
|
14
|
+
raise AssertionError(f"Arrays not close: max difference = {max_diff:.6e}")
|
|
15
|
+
return
|
|
16
|
+
except (ImportError, TypeError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
if ref != cand:
|
|
20
|
+
raise AssertionError(f"Values not equal: {ref!r} != {cand!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_comparison(
|
|
24
|
+
reference: Callable,
|
|
25
|
+
candidate: Callable,
|
|
26
|
+
args: tuple,
|
|
27
|
+
kwargs: dict,
|
|
28
|
+
compare_fn: Optional[Callable] = None,
|
|
29
|
+
) -> Tuple[str, Any, Any, Optional[str]]:
|
|
30
|
+
"""
|
|
31
|
+
Call reference and candidate with the same args, then compare outputs.
|
|
32
|
+
Returns (outcome, ref_result, cand_result, error_message).
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
ref_result = reference(*args, **kwargs)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return "error", None, None, f"Reference raised: {e}"
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
cand_result = candidate(*args, **kwargs)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
return "error", ref_result, None, f"Candidate raised: {e}"
|
|
43
|
+
|
|
44
|
+
compare = compare_fn if compare_fn is not None else default_compare
|
|
45
|
+
try:
|
|
46
|
+
compare(ref_result, cand_result)
|
|
47
|
+
return "passed", ref_result, cand_result, None
|
|
48
|
+
except AssertionError as e:
|
|
49
|
+
return "failed", ref_result, cand_result, str(e)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class GpuProofConfig:
|
|
9
|
+
enabled: bool = False
|
|
10
|
+
mode: str = "local"
|
|
11
|
+
output: str = "gpu-proof.json"
|
|
12
|
+
key_path: Optional[str] = None
|
|
13
|
+
signing_backend: str = "ed25519"
|
|
14
|
+
policy_path: Optional[str] = None
|
|
15
|
+
required_marker: str = "gpu_proof"
|
|
16
|
+
fail_on_skip: bool = False
|
|
17
|
+
fingerprint_paths: List[str] = field(default_factory=lambda: ["src", "tests"])
|
|
18
|
+
github_username: Optional[str] = None
|
|
19
|
+
max_age_days: int = 30
|
|
20
|
+
require_gpu: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_toml_defaults(root: Union[str, Path]) -> Dict[str, Any]:
|
|
24
|
+
"""Read the ``[tool.gpu_proof]`` table from ``<root>/pyproject.toml``.
|
|
25
|
+
|
|
26
|
+
Returns an empty dict when the file or table is absent or unparseable.
|
|
27
|
+
"""
|
|
28
|
+
pyproject = Path(root) / "pyproject.toml"
|
|
29
|
+
if not pyproject.is_file():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
with open(pyproject, "rb") as f:
|
|
33
|
+
data = tomllib.load(f)
|
|
34
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
35
|
+
return {}
|
|
36
|
+
table = data.get("tool", {}).get("gpu_proof", {})
|
|
37
|
+
return table if isinstance(table, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config(pytest_config) -> GpuProofConfig:
|
|
41
|
+
toml_cfg = load_toml_defaults(pytest_config.rootpath)
|
|
42
|
+
|
|
43
|
+
def opt(name, default=None):
|
|
44
|
+
try:
|
|
45
|
+
return pytest_config.getoption(name)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
def resolve(opt_name, toml_key, default):
|
|
50
|
+
"""Precedence: CLI flag (when it differs from its built-in default),
|
|
51
|
+
then [tool.gpu_proof] in pyproject.toml, then the built-in default."""
|
|
52
|
+
cli = opt(opt_name, default)
|
|
53
|
+
if cli != default:
|
|
54
|
+
return cli
|
|
55
|
+
toml_val = toml_cfg.get(toml_key)
|
|
56
|
+
if toml_val is not None:
|
|
57
|
+
return toml_val
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
raw_paths = resolve("--gpu-proof-fingerprint-paths", "fingerprint_paths", "src,tests")
|
|
61
|
+
if isinstance(raw_paths, str):
|
|
62
|
+
paths = [p.strip() for p in raw_paths.split(",") if p.strip()]
|
|
63
|
+
else:
|
|
64
|
+
paths = [str(p) for p in raw_paths]
|
|
65
|
+
|
|
66
|
+
max_age = toml_cfg.get("max_age_days")
|
|
67
|
+
max_age_days = int(max_age) if max_age is not None else 30
|
|
68
|
+
|
|
69
|
+
return GpuProofConfig(
|
|
70
|
+
enabled=bool(opt("--gpu-proof-enable", False)),
|
|
71
|
+
mode=resolve("--gpu-proof-mode", "mode", "local"),
|
|
72
|
+
output=resolve("--gpu-proof-out", "output", "gpu-proof.json"),
|
|
73
|
+
key_path=resolve("--gpu-proof-key", "key_path", None),
|
|
74
|
+
signing_backend=resolve("--gpu-proof-signing-backend", "signing_backend", "ed25519"),
|
|
75
|
+
policy_path=resolve("--gpu-proof-policy", "policy_path", None),
|
|
76
|
+
required_marker=resolve("--gpu-proof-required-marker", "required_marker", "gpu_proof"),
|
|
77
|
+
fail_on_skip=bool(resolve("--gpu-proof-fail-on-skip", "fail_on_skip", False)),
|
|
78
|
+
fingerprint_paths=paths,
|
|
79
|
+
github_username=resolve("--gpu-proof-github-user", "github_username", None),
|
|
80
|
+
max_age_days=max_age_days,
|
|
81
|
+
require_gpu=bool(toml_cfg.get("require_gpu", False)),
|
|
82
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .gitutils import get_tracked_files
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _hash_file(path: str, root: str) -> str:
|
|
10
|
+
h = hashlib.sha256()
|
|
11
|
+
with open(os.path.join(root, path), "rb") as f:
|
|
12
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
13
|
+
h.update(chunk)
|
|
14
|
+
return h.hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_fingerprint(paths: List[str], root: str = ".") -> dict:
|
|
18
|
+
tracked = get_tracked_files(paths, root)
|
|
19
|
+
file_hashes = {}
|
|
20
|
+
for p in tracked:
|
|
21
|
+
try:
|
|
22
|
+
file_hashes[p] = _hash_file(p, root)
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
canonical = json.dumps(
|
|
27
|
+
{"files": {k: file_hashes[k] for k in sorted(file_hashes)}},
|
|
28
|
+
sort_keys=True,
|
|
29
|
+
separators=(",", ":"),
|
|
30
|
+
)
|
|
31
|
+
digest = hashlib.sha256(canonical.encode()).hexdigest()
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"algorithm": "sha256",
|
|
35
|
+
"included_paths": sorted(paths),
|
|
36
|
+
"file_count": len(file_hashes),
|
|
37
|
+
"digest": digest,
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _git(*args) -> Optional[str]:
|
|
7
|
+
try:
|
|
8
|
+
result = subprocess.run(
|
|
9
|
+
["git"] + list(args),
|
|
10
|
+
capture_output=True,
|
|
11
|
+
text=True,
|
|
12
|
+
check=True,
|
|
13
|
+
)
|
|
14
|
+
return result.stdout.strip() or None
|
|
15
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_commit_sha() -> Optional[str]:
|
|
20
|
+
return _git("rev-parse", "HEAD")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_branch() -> Optional[str]:
|
|
24
|
+
return _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_dirty() -> bool:
|
|
28
|
+
# --ignore-submodules=untracked: untracked files INSIDE a submodule (build
|
|
29
|
+
# deps, caches) cannot change the code a receipt attests — the submodule's
|
|
30
|
+
# content is pinned by the parent's gitlink, and a *pin* change (a different
|
|
31
|
+
# or new commit checked out in the submodule) still reports as modified
|
|
32
|
+
# under this flag. Without it, a consumer whose submodule carries build
|
|
33
|
+
# artifacts can never produce a clean receipt (first hit: GATO's sqpcpu
|
|
34
|
+
# baseline submodule).
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "status", "--porcelain", "--ignore-submodules=untracked"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
check=True,
|
|
41
|
+
)
|
|
42
|
+
return bool(result.stdout.strip())
|
|
43
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_remote_url() -> Optional[str]:
|
|
48
|
+
return _git("remote", "get-url", "origin")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_github_username(remote_url: str) -> Optional[str]:
|
|
52
|
+
match = re.search(r"github\.com[:/]([^/]+)/", remote_url)
|
|
53
|
+
return match.group(1) if match else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_github_username() -> Optional[str]:
|
|
57
|
+
url = get_remote_url()
|
|
58
|
+
return extract_github_username(url) if url else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_gh_cli_login() -> Optional[str]:
|
|
62
|
+
"""The authenticated GitHub CLI user, if `gh` is installed and logged in.
|
|
63
|
+
|
|
64
|
+
This is the KEYHOLDER — the account whose github.com/<user>.keys will
|
|
65
|
+
verify the receipt — unlike the origin-remote owner, which for org-owned
|
|
66
|
+
repos is an org with no SSH keys.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=True,
|
|
74
|
+
timeout=10,
|
|
75
|
+
)
|
|
76
|
+
return result.stdout.strip() or None
|
|
77
|
+
except (subprocess.CalledProcessError, FileNotFoundError,
|
|
78
|
+
subprocess.TimeoutExpired):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_git_signing_key() -> Optional[str]:
|
|
83
|
+
val = _git("config", "--get", "user.signingKey")
|
|
84
|
+
if not val:
|
|
85
|
+
return None
|
|
86
|
+
import os
|
|
87
|
+
return os.path.expanduser(val)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_tracked_files(paths, root="."):
|
|
91
|
+
try:
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["git", "ls-files"] + list(paths),
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=True,
|
|
97
|
+
cwd=root,
|
|
98
|
+
)
|
|
99
|
+
return sorted(line for line in result.stdout.splitlines() if line)
|
|
100
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
101
|
+
import os
|
|
102
|
+
files = []
|
|
103
|
+
for path in paths:
|
|
104
|
+
full = os.path.join(root, path)
|
|
105
|
+
if os.path.isfile(full):
|
|
106
|
+
files.append(os.path.relpath(full, root))
|
|
107
|
+
elif os.path.isdir(full):
|
|
108
|
+
for dirpath, _, filenames in os.walk(full):
|
|
109
|
+
for fn in filenames:
|
|
110
|
+
fp = os.path.join(dirpath, fn)
|
|
111
|
+
files.append(os.path.relpath(fp, root))
|
|
112
|
+
return sorted(files)
|