runspecimen 0.2.0rc9__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.
- runspecimen/__init__.py +14 -0
- runspecimen/__main__.py +4 -0
- runspecimen/approve.py +218 -0
- runspecimen/atomic.py +50 -0
- runspecimen/certificate.py +272 -0
- runspecimen/cli.py +601 -0
- runspecimen/contract.py +421 -0
- runspecimen/dashboard.py +494 -0
- runspecimen/demo.py +50 -0
- runspecimen/errors.py +51 -0
- runspecimen/events.py +177 -0
- runspecimen/hashutil.py +168 -0
- runspecimen/lease.py +154 -0
- runspecimen/paths.py +85 -0
- runspecimen/postflight.py +218 -0
- runspecimen/preflight.py +148 -0
- runspecimen/py.typed +0 -0
- runspecimen/recovery.py +251 -0
- runspecimen/run.py +303 -0
- runspecimen/runtime.py +329 -0
- runspecimen/signing.py +616 -0
- runspecimen/state.py +36 -0
- runspecimen/status.py +74 -0
- runspecimen-0.2.0rc9.dist-info/METADATA +260 -0
- runspecimen-0.2.0rc9.dist-info/RECORD +29 -0
- runspecimen-0.2.0rc9.dist-info/WHEEL +5 -0
- runspecimen-0.2.0rc9.dist-info/entry_points.txt +2 -0
- runspecimen-0.2.0rc9.dist-info/licenses/LICENSE +188 -0
- runspecimen-0.2.0rc9.dist-info/top_level.txt +1 -0
runspecimen/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""RunSpecimen: one approved bounded run with tamper-evident receipts.
|
|
2
|
+
|
|
3
|
+
Local execution assurance for research and engineering workflows.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0rc9"
|
|
7
|
+
PRODUCT_NAME = "RunSpecimen"
|
|
8
|
+
|
|
9
|
+
# Canonical published docs (installed wheels do not embed the markdown tree).
|
|
10
|
+
DOCS_URLS = {
|
|
11
|
+
"about": "https://github.com/darashkevich/runspecimen/blob/main/docs/ABOUT.md",
|
|
12
|
+
"user_guide": "https://github.com/darashkevich/runspecimen/blob/main/docs/USER_GUIDE.md",
|
|
13
|
+
"faq": "https://github.com/darashkevich/runspecimen/blob/main/docs/FAQ.md",
|
|
14
|
+
}
|
runspecimen/__main__.py
ADDED
runspecimen/approve.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Interactive approval binding contract + source hashes with expiry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TextIO
|
|
10
|
+
|
|
11
|
+
from runspecimen.atomic import atomic_write_json, read_json
|
|
12
|
+
from runspecimen.contract import Contract, check_contract_paths, load_contract
|
|
13
|
+
from runspecimen.errors import ApprovalError, LeaseError
|
|
14
|
+
from runspecimen.events import EventLog, utc_now_iso
|
|
15
|
+
from runspecimen.hashutil import hash_source
|
|
16
|
+
from runspecimen.lease import hold_workspace_lease
|
|
17
|
+
from runspecimen.paths import (
|
|
18
|
+
APPROVAL_FILENAME,
|
|
19
|
+
ensure_dir,
|
|
20
|
+
resolve_workspace,
|
|
21
|
+
run_state_dir,
|
|
22
|
+
)
|
|
23
|
+
from runspecimen.state import load_state, update_state
|
|
24
|
+
from runspecimen.runtime import runtime_provenance
|
|
25
|
+
|
|
26
|
+
CONFIRM_PHRASE = "APPROVE"
|
|
27
|
+
_TERMINAL_PHASES = frozenset({"running", "completed", "failed", "postflighted", "abandoned"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def approval_path(state_dir: Path) -> Path:
|
|
31
|
+
return state_dir / APPROVAL_FILENAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_approval(state_dir: Path) -> dict | None:
|
|
35
|
+
path = approval_path(state_dir)
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return None
|
|
38
|
+
return read_json(path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require_interactive_tty(
|
|
42
|
+
stdin: TextIO | None = None,
|
|
43
|
+
stdout: TextIO | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
stdin = stdin or sys.stdin
|
|
46
|
+
stdout = stdout or sys.stdout
|
|
47
|
+
if not (
|
|
48
|
+
hasattr(stdin, "isatty")
|
|
49
|
+
and stdin.isatty()
|
|
50
|
+
and hasattr(stdout, "isatty")
|
|
51
|
+
and stdout.isatty()
|
|
52
|
+
):
|
|
53
|
+
raise ApprovalError(
|
|
54
|
+
"approval requires an interactive TTY on stdin and stdout "
|
|
55
|
+
"(refuse unattended / piped approval)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def approve_contract(
|
|
60
|
+
*,
|
|
61
|
+
contract_path: Path,
|
|
62
|
+
workspace: Path,
|
|
63
|
+
stdin: TextIO | None = None,
|
|
64
|
+
stdout: TextIO | None = None,
|
|
65
|
+
confirm_phrase: str = CONFIRM_PHRASE,
|
|
66
|
+
now: float | None = None,
|
|
67
|
+
skip_tty_check: bool = False,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Prompt on a TTY and write a binding approval document under workspace lease."""
|
|
70
|
+
stdin = stdin or sys.stdin
|
|
71
|
+
stdout = stdout or sys.stdout
|
|
72
|
+
if not skip_tty_check:
|
|
73
|
+
require_interactive_tty(stdin, stdout)
|
|
74
|
+
|
|
75
|
+
workspace = resolve_workspace(workspace)
|
|
76
|
+
contract = load_contract(contract_path)
|
|
77
|
+
check_contract_paths(contract, workspace)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with hold_workspace_lease(workspace, holder="approve"):
|
|
81
|
+
return _approve_under_lease(
|
|
82
|
+
contract=contract,
|
|
83
|
+
workspace=workspace,
|
|
84
|
+
stdin=stdin,
|
|
85
|
+
stdout=stdout,
|
|
86
|
+
confirm_phrase=confirm_phrase,
|
|
87
|
+
now=now,
|
|
88
|
+
)
|
|
89
|
+
except LeaseError as exc:
|
|
90
|
+
raise ApprovalError(str(exc)) from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _approve_under_lease(
|
|
94
|
+
*,
|
|
95
|
+
contract: Contract,
|
|
96
|
+
workspace: Path,
|
|
97
|
+
stdin: TextIO,
|
|
98
|
+
stdout: TextIO,
|
|
99
|
+
confirm_phrase: str,
|
|
100
|
+
now: float | None,
|
|
101
|
+
) -> dict:
|
|
102
|
+
state_dir = run_state_dir(workspace, contract.campaign_id, contract.run_id)
|
|
103
|
+
ensure_dir(state_dir)
|
|
104
|
+
state = load_state(state_dir)
|
|
105
|
+
phase = state.get("phase")
|
|
106
|
+
if phase in _TERMINAL_PHASES:
|
|
107
|
+
raise ApprovalError(
|
|
108
|
+
f"refuse re-approval: run already in phase={phase!r} "
|
|
109
|
+
f"({contract.campaign_id}/{contract.run_id})"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
source_hash, _manifest = hash_source(
|
|
113
|
+
workspace, list(contract.source.roots), list(contract.source.excludes)
|
|
114
|
+
)
|
|
115
|
+
runtime = runtime_provenance(contract, workspace)
|
|
116
|
+
|
|
117
|
+
stdout.write(
|
|
118
|
+
f"Approve bounded run?\n"
|
|
119
|
+
f" campaign: {contract.campaign_id}\n"
|
|
120
|
+
f" run_id: {contract.run_id}\n"
|
|
121
|
+
f" argv: {list(contract.argv)!r}\n"
|
|
122
|
+
f" cwd: {contract.cwd}\n"
|
|
123
|
+
f" sources: {list(contract.source.roots)!r}\n"
|
|
124
|
+
f" excludes: {list(contract.source.excludes)!r}\n"
|
|
125
|
+
f" outputs: {list(contract.asserted_output_paths)!r}\n"
|
|
126
|
+
f" timeout: {contract.caps.wall_timeout_sec}s\n"
|
|
127
|
+
f" capture: stdout={contract.caps.stdout_max_bytes}B "
|
|
128
|
+
f"stderr={contract.caps.stderr_max_bytes}B\n"
|
|
129
|
+
f" prior: {contract.predecessor!r}\n"
|
|
130
|
+
f" contract: {contract.contract_hash}\n"
|
|
131
|
+
f" source: {source_hash}\n"
|
|
132
|
+
f" runtime: {runtime['resolved_executable']}\n"
|
|
133
|
+
f" runtime#: {runtime['runtime_id']}\n"
|
|
134
|
+
f" ttl_sec: {contract.approval.ttl_sec}\n"
|
|
135
|
+
f"Type {confirm_phrase!r} to bind this approval: "
|
|
136
|
+
)
|
|
137
|
+
stdout.flush()
|
|
138
|
+
line = stdin.readline()
|
|
139
|
+
if line is None:
|
|
140
|
+
raise ApprovalError("no input for approval confirmation")
|
|
141
|
+
if line.strip() != confirm_phrase:
|
|
142
|
+
raise ApprovalError("approval aborted (confirmation phrase mismatch)")
|
|
143
|
+
|
|
144
|
+
# Re-check phase after interactive pause (still under lease).
|
|
145
|
+
state = load_state(state_dir)
|
|
146
|
+
phase = state.get("phase")
|
|
147
|
+
if phase in _TERMINAL_PHASES:
|
|
148
|
+
raise ApprovalError(
|
|
149
|
+
f"refuse re-approval: run already in phase={phase!r} "
|
|
150
|
+
f"({contract.campaign_id}/{contract.run_id})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
ts = time.time() if now is None else now
|
|
154
|
+
expires_at = ts + contract.approval.ttl_sec
|
|
155
|
+
doc = {
|
|
156
|
+
"approved_at": utc_now_iso(),
|
|
157
|
+
"approved_at_unix": ts,
|
|
158
|
+
"expires_at_unix": expires_at,
|
|
159
|
+
"campaign_id": contract.campaign_id,
|
|
160
|
+
"run_id": contract.run_id,
|
|
161
|
+
"contract_path": str(contract.path),
|
|
162
|
+
"contract_hash": contract.contract_hash,
|
|
163
|
+
"source_hash": source_hash,
|
|
164
|
+
"runtime": runtime,
|
|
165
|
+
"ttl_sec": contract.approval.ttl_sec,
|
|
166
|
+
"argv": list(contract.argv),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
atomic_write_json(approval_path(state_dir), doc)
|
|
170
|
+
log = EventLog.for_state_dir(state_dir)
|
|
171
|
+
log.append(
|
|
172
|
+
"approval",
|
|
173
|
+
{
|
|
174
|
+
"contract_hash": contract.contract_hash,
|
|
175
|
+
"source_hash": source_hash,
|
|
176
|
+
"runtime_id": runtime["runtime_id"],
|
|
177
|
+
"expires_at_unix": expires_at,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
update_state(
|
|
181
|
+
state_dir,
|
|
182
|
+
phase="approved",
|
|
183
|
+
campaign_id=contract.campaign_id,
|
|
184
|
+
run_id=contract.run_id,
|
|
185
|
+
contract_hash=contract.contract_hash,
|
|
186
|
+
source_hash=source_hash,
|
|
187
|
+
runtime=runtime,
|
|
188
|
+
approval_expires_at_unix=expires_at,
|
|
189
|
+
)
|
|
190
|
+
return doc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def approval_is_valid(
|
|
194
|
+
approval: dict,
|
|
195
|
+
contract: Contract,
|
|
196
|
+
source_hash: str,
|
|
197
|
+
*,
|
|
198
|
+
now: float | None = None,
|
|
199
|
+
) -> tuple[bool, str]:
|
|
200
|
+
ts = time.time() if now is None else now
|
|
201
|
+
if approval.get("contract_hash") != contract.contract_hash:
|
|
202
|
+
return False, "approval contract_hash mismatch (stale or wrong contract)"
|
|
203
|
+
if approval.get("source_hash") != source_hash:
|
|
204
|
+
return False, "approval source_hash mismatch (provenance changed)"
|
|
205
|
+
if approval.get("campaign_id") != contract.campaign_id or approval.get("run_id") != contract.run_id:
|
|
206
|
+
return False, "approval run identity mismatch"
|
|
207
|
+
expires = approval.get("expires_at_unix")
|
|
208
|
+
if isinstance(expires, bool) or not isinstance(expires, (int, float)):
|
|
209
|
+
return False, "approval missing or invalid expires_at_unix"
|
|
210
|
+
try:
|
|
211
|
+
finite_expiry = math.isfinite(expires)
|
|
212
|
+
except OverflowError:
|
|
213
|
+
finite_expiry = False
|
|
214
|
+
if not finite_expiry:
|
|
215
|
+
return False, "approval invalid expires_at_unix (must be finite)"
|
|
216
|
+
if ts >= expires:
|
|
217
|
+
return False, "approval expired (stale)"
|
|
218
|
+
return True, "ok"
|
runspecimen/atomic.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Atomic file writes for crash-safe state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
|
15
|
+
tmp_path = Path(tmp_name)
|
|
16
|
+
try:
|
|
17
|
+
with os.fdopen(fd, "wb") as fh:
|
|
18
|
+
fh.write(data)
|
|
19
|
+
fh.flush()
|
|
20
|
+
os.fsync(fh.fileno())
|
|
21
|
+
os.replace(tmp_path, path)
|
|
22
|
+
# Best-effort directory fsync for durability on crash.
|
|
23
|
+
try:
|
|
24
|
+
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
25
|
+
try:
|
|
26
|
+
os.fsync(dir_fd)
|
|
27
|
+
finally:
|
|
28
|
+
os.close(dir_fd)
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
except Exception:
|
|
32
|
+
try:
|
|
33
|
+
tmp_path.unlink(missing_ok=True)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
raise
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None:
|
|
40
|
+
atomic_write_bytes(path, text.encode(encoding))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def atomic_write_json(path: Path, obj: Any, *, sort_keys: bool = True) -> None:
|
|
44
|
+
payload = json.dumps(obj, indent=2, sort_keys=sort_keys, separators=(",", ": ")) + "\n"
|
|
45
|
+
atomic_write_text(path, payload)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_json(path: Path) -> Any:
|
|
49
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
50
|
+
return json.load(fh)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Tamper-evident run certificates and strict verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from runspecimen.atomic import atomic_write_json, read_json
|
|
9
|
+
from runspecimen.contract import Contract
|
|
10
|
+
from runspecimen.errors import CertificateError
|
|
11
|
+
from runspecimen.events import EventLog, utc_now_iso
|
|
12
|
+
from runspecimen.hashutil import canonical_json_bytes, hash_source, sha256_bytes, sha256_file
|
|
13
|
+
from runspecimen.paths import CERTIFICATE_FILENAME, STATE_FILENAME, ensure_within, run_state_dir
|
|
14
|
+
from runspecimen.state import load_state
|
|
15
|
+
from runspecimen.runtime import runtime_provenance
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def certificate_path(state_dir: Path) -> Path:
|
|
19
|
+
return state_dir / CERTIFICATE_FILENAME
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_certificate(state_dir: Path) -> dict[str, Any] | None:
|
|
23
|
+
path = certificate_path(state_dir)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return None
|
|
26
|
+
return read_json(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_certificate(state_dir: Path, cert: dict[str, Any]) -> None:
|
|
30
|
+
atomic_write_json(certificate_path(state_dir), cert)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_certificate(
|
|
34
|
+
*,
|
|
35
|
+
contract: Contract,
|
|
36
|
+
state: dict[str, Any],
|
|
37
|
+
source_hash: str,
|
|
38
|
+
output_digests: dict[str, str],
|
|
39
|
+
event_head: str,
|
|
40
|
+
approval: dict[str, Any] | None,
|
|
41
|
+
runtime: dict[str, Any],
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
body = {
|
|
44
|
+
"approval_expires_at_unix": (approval or {}).get("expires_at_unix"),
|
|
45
|
+
"campaign_id": contract.campaign_id,
|
|
46
|
+
"contract_hash": contract.contract_hash,
|
|
47
|
+
"event_head": event_head,
|
|
48
|
+
"exit_code": state.get("exit_code"),
|
|
49
|
+
"issued_at": utc_now_iso(),
|
|
50
|
+
"output_digests": dict(sorted(output_digests.items())),
|
|
51
|
+
"run_id": contract.run_id,
|
|
52
|
+
"run_result": state.get("run_result"),
|
|
53
|
+
"source_hash": source_hash,
|
|
54
|
+
"runtime": runtime,
|
|
55
|
+
}
|
|
56
|
+
certificate_id = sha256_bytes(canonical_json_bytes(body))
|
|
57
|
+
return {"certificate_id": certificate_id, **body}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _recompute_certificate_id(cert: dict[str, Any]) -> str:
|
|
61
|
+
material = {
|
|
62
|
+
"approval_expires_at_unix": cert.get("approval_expires_at_unix"),
|
|
63
|
+
"campaign_id": cert["campaign_id"],
|
|
64
|
+
"contract_hash": cert["contract_hash"],
|
|
65
|
+
"event_head": cert["event_head"],
|
|
66
|
+
"exit_code": cert.get("exit_code"),
|
|
67
|
+
"issued_at": cert["issued_at"],
|
|
68
|
+
"output_digests": cert["output_digests"],
|
|
69
|
+
"run_id": cert["run_id"],
|
|
70
|
+
"run_result": cert.get("run_result"),
|
|
71
|
+
"source_hash": cert["source_hash"],
|
|
72
|
+
"runtime": cert["runtime"],
|
|
73
|
+
}
|
|
74
|
+
return sha256_bytes(canonical_json_bytes(material))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _verify_issuance_ordering(log: EventLog, cert: dict[str, Any]) -> None:
|
|
78
|
+
records = log.read_all()
|
|
79
|
+
if not records:
|
|
80
|
+
raise CertificateError("event log empty; missing certificate issuance events")
|
|
81
|
+
|
|
82
|
+
assertions_idx = None
|
|
83
|
+
for i, rec in enumerate(records):
|
|
84
|
+
if rec.type == "postflight_assertions_ok" and rec.event_hash == cert["event_head"]:
|
|
85
|
+
assertions_idx = i
|
|
86
|
+
break
|
|
87
|
+
if assertions_idx is None:
|
|
88
|
+
raise CertificateError(
|
|
89
|
+
"certificate event_head does not match a postflight_assertions_ok event"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if assertions_idx + 1 >= len(records):
|
|
93
|
+
raise CertificateError("missing certificate_issued event after postflight_assertions_ok")
|
|
94
|
+
|
|
95
|
+
issued = records[assertions_idx + 1]
|
|
96
|
+
if issued.type != "certificate_issued":
|
|
97
|
+
raise CertificateError(
|
|
98
|
+
f"expected certificate_issued immediately after assertions_ok, got {issued.type!r}"
|
|
99
|
+
)
|
|
100
|
+
if issued.body.get("certificate_id") != cert["certificate_id"]:
|
|
101
|
+
raise CertificateError("certificate_issued event certificate_id mismatch")
|
|
102
|
+
if issued.body.get("event_head") != cert["event_head"]:
|
|
103
|
+
raise CertificateError("certificate_issued event event_head mismatch")
|
|
104
|
+
|
|
105
|
+
final_issued = None
|
|
106
|
+
for rec in records:
|
|
107
|
+
if (
|
|
108
|
+
rec.type == "certificate_issued"
|
|
109
|
+
and rec.body.get("certificate_id") == cert["certificate_id"]
|
|
110
|
+
):
|
|
111
|
+
final_issued = rec
|
|
112
|
+
if final_issued is None:
|
|
113
|
+
raise CertificateError("no matching certificate_issued event")
|
|
114
|
+
if records[-1].event_hash != final_issued.event_hash:
|
|
115
|
+
raise CertificateError("final event is not the matching certificate_issued event")
|
|
116
|
+
if final_issued.event_hash != issued.event_hash:
|
|
117
|
+
raise CertificateError("certificate issuance ordering fork detected")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _verify_live_outputs(workspace: Path, cert: dict[str, Any]) -> None:
|
|
121
|
+
digests = cert.get("output_digests")
|
|
122
|
+
if not isinstance(digests, dict):
|
|
123
|
+
raise CertificateError("certificate output_digests missing or invalid")
|
|
124
|
+
for rel, expected in digests.items():
|
|
125
|
+
if not isinstance(rel, str) or not isinstance(expected, str):
|
|
126
|
+
raise CertificateError("invalid output_digests entry")
|
|
127
|
+
path = ensure_within(workspace, Path(rel), label=f"output {rel!r}")
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
raise CertificateError(f"recorded output missing on disk: {rel}")
|
|
130
|
+
live = sha256_file(path)
|
|
131
|
+
if live != expected:
|
|
132
|
+
raise CertificateError(
|
|
133
|
+
f"live output digest mismatch for {rel}: expected {expected}, got {live}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def verify_run_receipt(
|
|
138
|
+
*,
|
|
139
|
+
workspace: Path,
|
|
140
|
+
campaign_id: str,
|
|
141
|
+
run_id: str,
|
|
142
|
+
contract: Contract | None = None,
|
|
143
|
+
require_live_provenance: bool = False,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""Strict verification of a postflighted run receipt.
|
|
146
|
+
|
|
147
|
+
Always checks: certificate integrity, state identity/phase, event chain,
|
|
148
|
+
assertions_ok→certificate_issued ordering, and live output digests.
|
|
149
|
+
|
|
150
|
+
When require_live_provenance is True, contract must be provided and current
|
|
151
|
+
contract/source hashes are rehashed and compared to the certificate/state.
|
|
152
|
+
"""
|
|
153
|
+
workspace = workspace.resolve()
|
|
154
|
+
state_dir = run_state_dir(workspace, campaign_id, run_id)
|
|
155
|
+
|
|
156
|
+
if not (state_dir / STATE_FILENAME).exists():
|
|
157
|
+
raise CertificateError("state.json missing (deleted or never written)")
|
|
158
|
+
|
|
159
|
+
cert = load_certificate(state_dir)
|
|
160
|
+
if cert is None:
|
|
161
|
+
raise CertificateError("certificate not found")
|
|
162
|
+
|
|
163
|
+
required = [
|
|
164
|
+
"certificate_id",
|
|
165
|
+
"campaign_id",
|
|
166
|
+
"run_id",
|
|
167
|
+
"contract_hash",
|
|
168
|
+
"source_hash",
|
|
169
|
+
"event_head",
|
|
170
|
+
"output_digests",
|
|
171
|
+
"issued_at",
|
|
172
|
+
"runtime",
|
|
173
|
+
]
|
|
174
|
+
for key in required:
|
|
175
|
+
if key not in cert:
|
|
176
|
+
raise CertificateError(f"certificate missing field: {key}")
|
|
177
|
+
|
|
178
|
+
if _recompute_certificate_id(cert) != cert["certificate_id"]:
|
|
179
|
+
raise CertificateError("certificate_id mismatch (tampered certificate body)")
|
|
180
|
+
|
|
181
|
+
if cert["campaign_id"] != campaign_id or cert["run_id"] != run_id:
|
|
182
|
+
raise CertificateError("certificate campaign_id/run_id does not match requested identity")
|
|
183
|
+
|
|
184
|
+
state = load_state(state_dir)
|
|
185
|
+
if state.get("phase") != "postflighted":
|
|
186
|
+
raise CertificateError(f"state phase must be postflighted, got {state.get('phase')!r}")
|
|
187
|
+
|
|
188
|
+
for field in ("campaign_id", "run_id", "contract_hash", "source_hash", "certificate_id"):
|
|
189
|
+
if state.get(field) != cert[field]:
|
|
190
|
+
raise CertificateError(f"state {field} does not match certificate")
|
|
191
|
+
|
|
192
|
+
if state.get("campaign_id") != campaign_id or state.get("run_id") != run_id:
|
|
193
|
+
raise CertificateError("state campaign_id/run_id does not match requested identity")
|
|
194
|
+
|
|
195
|
+
log = EventLog.for_state_dir(state_dir)
|
|
196
|
+
ok, msg = log.verify_chain()
|
|
197
|
+
if not ok:
|
|
198
|
+
raise CertificateError(f"event log chain invalid: {msg}")
|
|
199
|
+
|
|
200
|
+
_verify_issuance_ordering(log, cert)
|
|
201
|
+
_verify_live_outputs(workspace, cert)
|
|
202
|
+
|
|
203
|
+
if require_live_provenance:
|
|
204
|
+
if contract is None:
|
|
205
|
+
raise CertificateError("live provenance verification requires a contract")
|
|
206
|
+
if contract.campaign_id != campaign_id or contract.run_id != run_id:
|
|
207
|
+
raise CertificateError("contract identity does not match campaign_id/run_id")
|
|
208
|
+
if contract.contract_hash != cert["contract_hash"]:
|
|
209
|
+
raise CertificateError(
|
|
210
|
+
f"current contract hash mismatch: live={contract.contract_hash} "
|
|
211
|
+
f"cert={cert['contract_hash']}"
|
|
212
|
+
)
|
|
213
|
+
if state.get("contract_hash") != contract.contract_hash:
|
|
214
|
+
raise CertificateError("state contract_hash does not match live contract")
|
|
215
|
+
live_source, _ = hash_source(
|
|
216
|
+
workspace, list(contract.source.roots), list(contract.source.excludes)
|
|
217
|
+
)
|
|
218
|
+
if live_source != cert["source_hash"]:
|
|
219
|
+
raise CertificateError(
|
|
220
|
+
f"current source hash mismatch: live={live_source} cert={cert['source_hash']}"
|
|
221
|
+
)
|
|
222
|
+
if state.get("source_hash") != live_source:
|
|
223
|
+
raise CertificateError("state source_hash does not match live source")
|
|
224
|
+
live_runtime = runtime_provenance(contract, workspace)
|
|
225
|
+
if live_runtime.get("runtime_id") != cert["runtime"].get("runtime_id"):
|
|
226
|
+
raise CertificateError("current runtime provenance does not match certificate")
|
|
227
|
+
if state.get("runtime", {}).get("runtime_id") != live_runtime.get("runtime_id"):
|
|
228
|
+
raise CertificateError("state runtime provenance does not match live runtime")
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"ok": True,
|
|
232
|
+
"certificate_id": cert["certificate_id"],
|
|
233
|
+
"event_chain": msg,
|
|
234
|
+
"event_head": cert["event_head"],
|
|
235
|
+
"campaign_id": campaign_id,
|
|
236
|
+
"run_id": run_id,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def verify_certificate(
|
|
241
|
+
state_dir: Path,
|
|
242
|
+
*,
|
|
243
|
+
workspace: Path | None = None,
|
|
244
|
+
contract: Contract | None = None,
|
|
245
|
+
require_live_provenance: bool = False,
|
|
246
|
+
campaign_id: str | None = None,
|
|
247
|
+
run_id: str | None = None,
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
"""Wrapper around verify_run_receipt; infers workspace from state_dir when omitted."""
|
|
250
|
+
state_dir = state_dir.resolve()
|
|
251
|
+
if workspace is None:
|
|
252
|
+
# state_dir = ws/.runspecimen/runs/{camp}/{run}
|
|
253
|
+
if len(state_dir.parents) < 4 or state_dir.parents[2].name != ".runspecimen":
|
|
254
|
+
raise CertificateError("cannot infer workspace from state_dir")
|
|
255
|
+
workspace = state_dir.parents[3]
|
|
256
|
+
|
|
257
|
+
state = load_state(state_dir)
|
|
258
|
+
camp = campaign_id or state.get("campaign_id")
|
|
259
|
+
rid = run_id or state.get("run_id")
|
|
260
|
+
if not camp or not rid:
|
|
261
|
+
cert = load_certificate(state_dir)
|
|
262
|
+
if cert is None:
|
|
263
|
+
raise CertificateError("certificate not found and state lacks identity")
|
|
264
|
+
camp = camp or cert["campaign_id"]
|
|
265
|
+
rid = rid or cert["run_id"]
|
|
266
|
+
return verify_run_receipt(
|
|
267
|
+
workspace=workspace,
|
|
268
|
+
campaign_id=str(camp),
|
|
269
|
+
run_id=str(rid),
|
|
270
|
+
contract=contract,
|
|
271
|
+
require_live_provenance=require_live_provenance,
|
|
272
|
+
)
|