#!/usr/bin/env python3 """Check an OpenRepro attempt's evidence log without installing anything. python3 openrepro_verify.py ATTEMPT_DIR_OR_BUNDLE [...] [--expect-head HASH | --anchors heads.jsonl | --manifest release.json] Uses only the Python standard library (3.8+). Implements the recipe in SPEC.md: 1. Every line of log.jsonl is canonical JSON with only finite numbers. 2. Every event's hash is the SHA-256 of that line with its top-level "hash" member removed, computed two independent ways (byte-level and re-serialized), which must agree. 3. seq counts 0, 1, 2, ... and each prev is the previous event's hash (the first is 64 zeros). 4. Every artifact an event names exists in artifacts/ and has the recorded SHA-256. 5. If report.json is present, it is finite JSON and its log.head_hash and log.events match the log. 6. With --expect-head, the final hash equals the one published elsewhere (an anchor); with --anchors, each attempt's final hash, event count and report.json SHA-256 equal its line in a published anchors file; with --manifest, the SHA-256 of every report representation (json, md, html) and of the bundle equal the published release manifest. An argument may be an attempt directory or an evidence bundle (.tar.gz), which is unpacked safely into a temporary directory first. What this proves: the log is internally consistent, the artifacts are the ones the log names, the report refers to this log, and (with an anchor or manifest) nothing was replaced after publication. It does not re-judge claims or re-derive the report: `repro verify` in the engine does that. Exit status is 0 when every attempt passes and 1 otherwise. """ import argparse import hashlib import json import sys import tarfile import tempfile from pathlib import Path, PurePosixPath GENESIS = "0" * 64 def canonical(obj): return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) def _reject_nonfinite(name): raise ValueError("non-finite number %s" % name) def loads(text): """json.loads that refuses NaN and Infinity, which canonical JSON never contains.""" return json.loads(text, parse_constant=_reject_nonfinite) def sha256_bytes(b): return hashlib.sha256(b).hexdigest() def sha256_file(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def check_attempt(attempt_dir, expect_head=None): """Return (problems, info) for one attempt directory.""" d = Path(attempt_dir) log_path = d / "log.jsonl" problems = [] if not log_path.is_file(): return [f"{log_path} not found"], {} raw = log_path.read_bytes() if raw and not raw.endswith(b"\n"): problems.append("log.jsonl does not end with a newline (possibly truncated)") lines = [ln for ln in raw.split(b"\n") if ln.strip()] prev = GENESIS artifacts_seen = set() for i, line in enumerate(lines): try: ev = loads(line.decode("utf-8")) except (UnicodeDecodeError, ValueError) as e: problems.append(f"event {i}: not valid finite UTF-8 JSON ({e})") break if not isinstance(ev, dict) or not isinstance(ev.get("hash"), str): problems.append(f"event {i}: missing hash") break if canonical(ev).encode("utf-8") != line: problems.append(f"event {i}: line is not in canonical form") if ev.get("seq") != i: problems.append(f"event {i}: seq is {ev.get('seq')!r}, expected {i}") if ev.get("prev") != prev: problems.append(f"event {i}: prev does not match the hash of event {i - 1}") # Recipe A (byte-level): drop the top-level "hash" member from the stored line. member = b'"hash":"' + ev["hash"].encode("ascii") + b'",' h_bytes = sha256_bytes(line.replace(member, b"", 1)) if line.count(member) == 1 else None # Recipe B (structural): re-serialize the event without its hash. body = {k: v for k, v in ev.items() if k != "hash"} h_struct = sha256_bytes(canonical(body).encode("utf-8")) if h_struct != ev["hash"] or h_bytes != ev["hash"]: problems.append(f"event {i}: content does not match its hash") prev = ev["hash"] data = ev.get("data") if isinstance(ev.get("data"), dict) else {} for art in data.get("artifacts") or []: name = art.get("name", "") artifacts_seen.add(name) p = d / "artifacts" / name if "/" in name or name.startswith("."): problems.append(f"event {i}: artifact name {name!r} is not a plain file name") elif not p.is_file(): problems.append(f"event {i}: artifact {name} is missing") elif sha256_file(p) != art.get("sha256"): problems.append(f"event {i}: artifact {name} does not match its recorded SHA-256") head = prev info = {"events": len(lines), "head": head, "artifacts": len(artifacts_seen)} report = d / "report.json" if report.is_file(): try: log_ref = loads(report.read_text(encoding="utf-8")).get("log") or {} except ValueError as e: log_ref = {} problems.append(f"report.json is not valid finite JSON ({e})") if log_ref.get("head_hash") != head: problems.append("report.json cites a different final hash than the log has") if log_ref.get("events") != len(lines): problems.append(f"report.json cites {log_ref.get('events')} events; the log has {len(lines)}") info["report"] = True info["report_sha256"] = sha256_file(report) info["files"] = {f: sha256_file(d / f) for f in ("report.json", "report.md", "report.html") if (d / f).is_file()} if expect_head and expect_head.lower() != head: problems.append(f"final hash {head} does not equal the expected {expect_head}") return problems, info def unpack_bundle(path, dest): """Extract a bundle into dest, refusing links, devices and paths that escape it. Returns the attempt dir.""" with tarfile.open(path, "r:gz") as tar: members = tar.getmembers() tops = set() for m in members: parts = PurePosixPath(m.name).parts if m.name.startswith("/") or ".." in parts or not (m.isfile() or m.isdir()): raise ValueError(f"unsafe entry in bundle: {m.name!r}") tops.add(parts[0]) if len(tops) != 1: raise ValueError("a bundle must hold exactly one attempt directory") for m in members: target = Path(dest, *PurePosixPath(m.name).parts) if m.isdir(): target.mkdir(parents=True, exist_ok=True) else: target.parent.mkdir(parents=True, exist_ok=True) with tar.extractfile(m) as src, open(target, "wb") as out: out.write(src.read()) return Path(dest, tops.pop()) def load_anchors(path): rows = {} for line in Path(path).read_text(encoding="utf-8").splitlines(): if line.strip(): row = loads(line) rows[row["attempt_id"]] = row return rows MANIFEST_SCHEMAS = ("openrepro.release/v1", "openrepro.release/v2") def load_manifest(path): """The release manifest (schema openrepro.release/v2; v1 is still read). Every row must name an attempt_id, an event count, a head hash and the SHA-256 of report.json; a manifest that does not is refused.""" doc = loads(Path(path).read_text(encoding="utf-8")) if not isinstance(doc, dict) or doc.get("schema") not in MANIFEST_SCHEMAS: raise ValueError("not a release manifest (schema must be one of %s)" % ", ".join(MANIFEST_SCHEMAS)) rows = {} for row in doc.get("attempts", []): if not isinstance(row, dict) or not isinstance(row.get("attempt_id"), str): raise ValueError("manifest row without attempt_id") files = row.get("files") if not isinstance(row.get("events"), int) or not isinstance(row.get("head_hash"), str) or not isinstance(files, dict) \ or file_digest(files.get("report.json")) is None: raise ValueError("manifest row %s lacks events, head_hash or the report.json digest" % row["attempt_id"]) rows[row["attempt_id"]] = row return rows def file_digest(entry): """A file's digest in a manifest row: {"sha256": ...} (v2) or a plain digest string (v1).""" if isinstance(entry, dict): entry = entry.get("sha256") return entry if isinstance(entry, str) and len(entry) == 64 else None def bundle_digest(row): b = row.get("bundle") if isinstance(b, dict): return file_digest(b) return file_digest(row.get("bundle_sha256")) def check_anchor(row, info): """Problems comparing one attempt's info with its anchors line.""" out = [] if row.get("head_hash") != info.get("head") or row.get("events") != info.get("events"): out.append("final hash or event count differs from the anchors file") if row.get("report_sha256") is not None: if info.get("report_sha256") is None: out.append("the anchors file pins report.json but the attempt has none") elif row["report_sha256"] != info["report_sha256"]: out.append("report.json differs from the SHA-256 in the anchors file") return out def check_manifest(row, info, bundle_path=None): """Problems comparing one attempt's report files (and bundle, when given) with the release manifest.""" out = [] if row.get("head_hash") != info.get("head") or row.get("events") != info.get("events"): out.append("final hash or event count differs from the release manifest") for name, entry in (row.get("files") or {}).items(): digest = file_digest(entry) have = (info.get("files") or {}).get(name) if digest is None: out.append("%s has no usable digest in the release manifest" % name) elif have is None: out.append("%s is pinned by the release manifest but missing here" % name) elif have != digest: out.append("%s differs from the SHA-256 in the release manifest" % name) if bundle_path is not None and bundle_digest(row): if sha256_file(bundle_path) != bundle_digest(row): out.append("the bundle differs from the SHA-256 in the release manifest") return out def main(argv=None): ap = argparse.ArgumentParser(description="Verify OpenRepro evidence logs (stdlib only).") ap.add_argument("attempts", nargs="+", help="attempt directories or evidence bundles (.tar.gz)") ap.add_argument("--expect-head", help="final hash published elsewhere; only with one attempt") ap.add_argument("--anchors", help="a published anchors file (heads.jsonl) to check every attempt against") ap.add_argument("--manifest", help="a published release manifest (release.json) to check report files and bundles against") args = ap.parse_args(argv) if args.expect_head and len(args.attempts) != 1: ap.error("--expect-head takes exactly one attempt directory") try: anchors = load_anchors(args.anchors) if args.anchors else None manifest = load_manifest(args.manifest) if args.manifest else None except (OSError, ValueError, KeyError) as e: ap.error("cannot read anchors or manifest: %s" % e) failed = 0 for a in args.attempts: is_bundle = str(a).endswith((".tar.gz", ".tgz")) with tempfile.TemporaryDirectory() as tmp: try: d = unpack_bundle(a, tmp) if is_bundle else Path(a) except (OSError, ValueError, tarfile.TarError) as e: failed += 1 print(f"FAIL {Path(a).name}\n - cannot open bundle: {e}") continue problems, info = check_attempt(d, args.expect_head) name = d.resolve().name if d.exists() else d.name if anchors is not None: row = anchors.get(name) if row is None: problems.append("not listed in the anchors file") else: problems += check_anchor(row, info) if manifest is not None: row = manifest.get(name) if row is None: problems.append("not listed in the release manifest") else: problems += check_manifest(row, info, Path(a) if is_bundle else None) if problems: failed += 1 print(f"FAIL {name}") for p in problems: print(f" - {p}") else: extra = ", report.json consistent" if info.get("report") else "" print(f"OK {name}: {info['events']} events, {info['artifacts']} artifacts{extra}") print(f" final hash {info['head']}") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())