diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b7556a..99ee9fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,8 @@ jobs: scripts/canonical_json.py scripts/cut_release_manifest.py scripts/verify_release_chain.py + scripts/ots_anchor.py + tests/test_ots_anchor.py - name: Test Arch surface run: > @@ -157,6 +159,7 @@ jobs: tests/test_policyengine_ledger.py tests/test_release_chain.py tests/test_thesis_append_adversarial.py + tests/test_ots_anchor.py -q - name: Build target input database diff --git a/README.md b/README.md index d65fb107..84620d48 100644 --- a/README.md +++ b/README.md @@ -572,3 +572,11 @@ package with this repository's trust pins committed in `scripts/receipt_pins.py`; see `releases/README.md` (immutable post-genesis, like everything under `releases/`) for the schema, offline verification procedure, and security limits. + +Each release manifest is additionally anchored in Bitcoin through +OpenTimestamps: `ots/.json.ots` timestamps the manifest's exact +bytes, so the anteriority of the journal state it commits to does not +depend on trusting this repository's git history. A scheduled job stamps +new manifests and upgrades pending proofs daily. See `ots/README.md` for +what the proofs do and do not establish and the two-command verification +procedure. diff --git a/ots/0000-307cedbc91de43be.json.ots b/ots/0000-307cedbc91de43be.json.ots new file mode 100644 index 00000000..aec5cfeb Binary files /dev/null and b/ots/0000-307cedbc91de43be.json.ots differ diff --git a/ots/0001-916626696d034b80.json.ots b/ots/0001-916626696d034b80.json.ots new file mode 100644 index 00000000..c38f62aa Binary files /dev/null and b/ots/0001-916626696d034b80.json.ots differ diff --git a/ots/0002-a69272175b73c83b.json.ots b/ots/0002-a69272175b73c83b.json.ots new file mode 100644 index 00000000..6e4747c7 Binary files /dev/null and b/ots/0002-a69272175b73c83b.json.ots differ diff --git a/ots/0003-cfae6e9b4524db6d.json.ots b/ots/0003-cfae6e9b4524db6d.json.ots new file mode 100644 index 00000000..e9464fa3 Binary files /dev/null and b/ots/0003-cfae6e9b4524db6d.json.ots differ diff --git a/ots/0004-36322993cf45b6d1.json.ots b/ots/0004-36322993cf45b6d1.json.ots new file mode 100644 index 00000000..2462b15d Binary files /dev/null and b/ots/0004-36322993cf45b6d1.json.ots differ diff --git a/ots/0005-9bcc4ff6b3fad5d2.json.ots b/ots/0005-9bcc4ff6b3fad5d2.json.ots new file mode 100644 index 00000000..aba2a7ca Binary files /dev/null and b/ots/0005-9bcc4ff6b3fad5d2.json.ots differ diff --git a/ots/0006-770683e59da14f45.json.ots b/ots/0006-770683e59da14f45.json.ots new file mode 100644 index 00000000..08a979bc Binary files /dev/null and b/ots/0006-770683e59da14f45.json.ots differ diff --git a/ots/0007-2b5ed02908832f0c.json.ots b/ots/0007-2b5ed02908832f0c.json.ots new file mode 100644 index 00000000..176a3b31 Binary files /dev/null and b/ots/0007-2b5ed02908832f0c.json.ots differ diff --git a/ots/0008-070e797b855dce92.json.ots b/ots/0008-070e797b855dce92.json.ots new file mode 100644 index 00000000..1156f28f Binary files /dev/null and b/ots/0008-070e797b855dce92.json.ots differ diff --git a/ots/0009-995768a31dd8fa6d.json.ots b/ots/0009-995768a31dd8fa6d.json.ots new file mode 100644 index 00000000..dd30506b Binary files /dev/null and b/ots/0009-995768a31dd8fa6d.json.ots differ diff --git a/ots/0010-6ba8c08f34189164.json.ots b/ots/0010-6ba8c08f34189164.json.ots new file mode 100644 index 00000000..00bc5b51 Binary files /dev/null and b/ots/0010-6ba8c08f34189164.json.ots differ diff --git a/ots/0011-34319583df55ce83.json.ots b/ots/0011-34319583df55ce83.json.ots new file mode 100644 index 00000000..f765d6f5 Binary files /dev/null and b/ots/0011-34319583df55ce83.json.ots differ diff --git a/ots/0012-3a5ef7eeee484370.json.ots b/ots/0012-3a5ef7eeee484370.json.ots new file mode 100644 index 00000000..849d7094 Binary files /dev/null and b/ots/0012-3a5ef7eeee484370.json.ots differ diff --git a/ots/0013-d47323bbaacda2d1.json.ots b/ots/0013-d47323bbaacda2d1.json.ots new file mode 100644 index 00000000..ef93c102 Binary files /dev/null and b/ots/0013-d47323bbaacda2d1.json.ots differ diff --git a/ots/0014-bd12e9e3e79a5529.json.ots b/ots/0014-bd12e9e3e79a5529.json.ots new file mode 100644 index 00000000..3d6afc19 Binary files /dev/null and b/ots/0014-bd12e9e3e79a5529.json.ots differ diff --git a/ots/README.md b/ots/README.md new file mode 100644 index 00000000..0cb9a7e3 --- /dev/null +++ b/ots/README.md @@ -0,0 +1,82 @@ +# Bitcoin-anchored checkpoints (OpenTimestamps) + +Every witnessed release manifest in `releases/manifests/` has a sibling proof +here: `ots/.json.ots` is an [OpenTimestamps](https://opentimestamps.org) +timestamp over the exact bytes of `releases/manifests/.json` — the same +bytes the two RFC 3161 authorities witness and the pinned producer key signs. + +## What this adds + +`releases/README.md` states the limit of the existing scheme: internal +verification proves a clone is self-consistent, but cannot by itself +distinguish the original history from a complete, freshly witnessed +replacement fork, so verifiers must retain an external checkpoint. These +proofs are that checkpoint, kept in a system the operator does not control. + +A completed proof carries a Bitcoin block attestation: the manifest bytes +existed no later than that block's time. Because each manifest commits to the +full journal bytes (`state.jsonlSha256`, `state.lineCount`), the immutable +prefix (`state.immutablePrefixSha256`), and the previous manifest +(`previousManifestSha256`), one attestation bounds the existence time of the +whole journal state and manifest chain it commits to. A rewritten history +would need its own anchors, and Bitcoin will only ever attest the time the +replacement was actually made — backdating is not available to anyone, +including us. + +The proofs do not add uniqueness: like the RFC 3161 receipts, they cannot +prove that no parallel fork exists, when GitHub accepted a proposal, or that +a manifest's claims are true. They move the anteriority bound outside the +operator's git history; the other caveats in `releases/README.md` stand. + +Anchoring began 2026-08-19. Every release manifest that existed then, +releases 0000 through 0014, was stamped that day, so their Bitcoin bounds +start there; the RFC 3161 receipt times remain the earlier per-release +witnesses. Later manifests are stamped by the scheduled job soon after they +land. + +## Verify + +From a clone, first re-verify the witnessed chain (this recomputes the +journal digest that the head manifest commits to), then check any release's +proof against Bitcoin: + +```console +python3 scripts/verify_release_chain.py --full +ots verify -f releases/manifests/.json ots/.json.ots +``` + +The `ots` command is the OpenTimestamps client +(`pip install opentimestamps-client`, or run it as +`uvx --from opentimestamps-client ots`). Full verification checks the +attested block header against a local Bitcoin node. Without a node, run + +```console +ots --no-bitcoin verify -f releases/manifests/.json ots/.json.ots +``` + +which validates that the proof commits to the file's exact bytes and prints +the Bitcoin block height and merkle root to check against any block source +you trust. A proof whose attestation has not yet been aggregated into +Bitcoin reports `Pending confirmation in Bitcoin blockchain`; pending proofs +are upgraded in place by the scheduled job once the calendar's aggregate +transaction confirms. + +To sweep every proof in the repository at once: + +```console +python3 scripts/ots_anchor.py verify +``` + +This fails on any digest mismatch or missing proof, and with +`--require-bitcoin` also fails while any attestation is still pending. + +## Operations + +A scheduled GitHub Actions job (workflow on the default branch) checks out +the journal branch and runs `python scripts/ots_anchor.py run` daily: it +stamps any manifest that lacks a proof, tries to upgrade pending proofs, and +commits the result. The run is idempotent. Stamping happens on a temporary +copy so nothing is ever written under `releases/`, which the append gate +keeps closed to anything but exact release bundles. Once a proof carries a +Bitcoin attestation it is left untouched so its committed bytes stay stable; +`git log` retains every earlier pending version. diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py new file mode 100644 index 00000000..ee733726 --- /dev/null +++ b/scripts/ots_anchor.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Anchor witnessed release manifests in Bitcoin via OpenTimestamps. + +Each release manifest under ``releases/manifests/.json`` is already +witnessed by two RFC 3161 authorities and a pinned producer signature over its +exact bytes. This tool adds an operator-independent witness: an OpenTimestamps +proof over those same exact bytes, committed as ``ots/.json.ots``. +Because manifest ``state.jsonlSha256`` covers the full journal bytes and +``previousManifestSha256`` chains every earlier manifest, a Bitcoin +attestation over one manifest bounds the existence time of the whole journal +state it commits to. + +Proofs live in the top-level ``ots/`` directory, never under ``releases/``: +the append gate keeps ``releases/`` closed to anything but exact release +bundles, and OpenTimestamps upgrades rewrite proof files in place, which the +release-history immutability check would reject. + +Subcommands: + +- ``run``: stamp any manifest that lacks a proof, then try to upgrade pending + proofs to complete Bitcoin attestations. Idempotent; safe on a schedule. +- ``verify``: check every proof against its manifest's current bytes and + report attestation status. Exits nonzero on digest mismatch or a manifest + with no proof. +- ``status``: list proofs and whether each is pending or Bitcoin-complete. + +Requires the ``ots`` CLI (PyPI ``opentimestamps-client``); stamping and +upgrading contact public calendar servers, verification of a complete proof +against Bitcoin needs a local node or the printed manual block check. +""" + +from __future__ import annotations + +import argparse +import hashlib +import pathlib +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MANIFEST_DIR = pathlib.Path("releases/manifests") +OTS_DIR = pathlib.Path("ots") +MANIFEST_NAME_RE = re.compile(r"^(\d{4})-([0-9a-f]{16})\.json$") +SUBPROCESS_TIMEOUT = 300 + +# Stable output substrings observed from opentimestamps-client 0.7.2. +_MISMATCH_TEXT = "File does not match original" +_PENDING_TEXT = "Pending confirmation in Bitcoin blockchain" +_MANUAL_TEXT = "To verify manually, check that Bitcoin block" +_NO_NODE_TEXT = "Could not connect to Bitcoin node" +_UPGRADE_PENDING_TEXT = "Timestamp not complete" +_BITCOIN_ATTESTATION_TEXT = "BitcoinBlockHeaderAttestation" +_PENDING_ATTESTATION_TEXT = "PendingAttestation" + + +class AnchorError(RuntimeError): + """A condition that must stop the anchoring run.""" + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(65536), b""): + digest.update(block) + return digest.hexdigest() + + +def discover_manifests(root: pathlib.Path) -> list[pathlib.Path]: + directory = root / MANIFEST_DIR + if not directory.is_dir(): + raise AnchorError(f"manifest directory missing: {directory}") + manifests = sorted( + path + for path in directory.iterdir() + if path.is_file() and MANIFEST_NAME_RE.match(path.name) + ) + if not manifests: + raise AnchorError(f"no release manifests found in {directory}") + return manifests + + +def check_manifest_name_digest(manifest: pathlib.Path) -> str: + """Refuse to anchor bytes that contradict the manifest's own filename.""" + + match = MANIFEST_NAME_RE.match(manifest.name) + if match is None: # discover_manifests already filtered on the pattern + raise AnchorError(f"unexpected manifest filename: {manifest.name}") + digest = sha256_file(manifest) + if digest[:16] != match.group(2): + raise AnchorError( + f"manifest {manifest.name} bytes hash to {digest[:16]}..., " + "which contradicts the filename; refusing to anchor" + ) + return digest + + +def proof_path(root: pathlib.Path, manifest: pathlib.Path) -> pathlib.Path: + return root / OTS_DIR / f"{manifest.name}.ots" + + +def _run_ots( + ots_bin: list[str], arguments: list[str], *, timeout: int = SUBPROCESS_TIMEOUT +) -> subprocess.CompletedProcess[str]: + command = [*ots_bin, *arguments] + try: + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise AnchorError( + f"ots binary not found ({command[0]!r}); install " + "opentimestamps-client or pass --ots-bin" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AnchorError(f"ots timed out: {' '.join(command)}") from exc + + +def stamp_manifest( + root: pathlib.Path, manifest: pathlib.Path, ots_bin: list[str] +) -> pathlib.Path: + """Stamp a temporary copy so nothing is ever written under releases/.""" + + destination = proof_path(root, manifest) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ots-anchor-") as name: + working_copy = pathlib.Path(name) / manifest.name + shutil.copyfile(manifest, working_copy) + completed = _run_ots(ots_bin, ["stamp", str(working_copy)]) + produced = working_copy.with_name(working_copy.name + ".ots") + if completed.returncode != 0 or not produced.is_file(): + raise AnchorError( + f"ots stamp failed for {manifest.name}: " + f"{completed.stderr.strip() or completed.stdout.strip()}" + ) + shutil.move(str(produced), destination) + return destination + + +def proof_is_complete(proof: pathlib.Path, ots_bin: list[str]) -> bool: + """True when the local proof file carries a Bitcoin attestation. + + ``ots info`` reads only the proof file, no network. An upgraded proof can + still list leftover ``PendingAttestation`` entries from calendars that + have not answered; one Bitcoin block attestation is what matters, and a + complete proof is left untouched so its committed bytes stay stable. + """ + + completed = _run_ots(ots_bin, ["info", str(proof)]) + output = completed.stdout + completed.stderr + if completed.returncode != 0: + raise AnchorError(f"ots info failed for {proof.name}: {output.strip()}") + if _BITCOIN_ATTESTATION_TEXT in output: + return True + if _PENDING_ATTESTATION_TEXT in output: + return False + raise AnchorError( + f"proof {proof.name} lists neither a Bitcoin nor a pending " + "attestation; refusing to guess" + ) + + +def upgrade_proof(proof: pathlib.Path, ots_bin: list[str]) -> None: + """Try to fold a Bitcoin attestation into a pending proof. + + Only called on proofs classified as pending. A still-pending calendar + answer is normal; anything else nonzero is a real failure. The client + leaves a ``.bak`` beside an upgraded proof; git history already keeps + the pending version, so the backup is removed. + """ + + completed = _run_ots(ots_bin, ["upgrade", str(proof)]) + output = completed.stdout + completed.stderr + backup = proof.with_name(proof.name + ".bak") + if backup.is_file(): + backup.unlink() + if completed.returncode == 0: + return + if _UPGRADE_PENDING_TEXT in output or _PENDING_TEXT in output: + return + raise AnchorError(f"ots upgrade failed for {proof.name}: {output.strip()}") + + +def classify_proof( + manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] +) -> str: + """Classify a proof against the manifest's current bytes. + + Returns one of ``"bitcoin"`` (complete attestation, verified or reported + for manual block check), ``"pending"`` (bound to these bytes, calendar + attestation not yet in Bitcoin), or ``"mismatch"``. + """ + + completed = _run_ots( + ots_bin, ["--no-bitcoin", "verify", "-f", str(manifest), str(proof)] + ) + output = completed.stdout + completed.stderr + if _MISMATCH_TEXT in output: + return "mismatch" + if completed.returncode == 0 or _MANUAL_TEXT in output: + return "bitcoin" + if _PENDING_TEXT in output: + return "pending" + if _NO_NODE_TEXT in output: + return "bitcoin" + raise AnchorError( + f"unrecognized ots verify outcome for {proof.name}: {output.strip()}" + ) + + +def command_run(root: pathlib.Path, ots_bin: list[str]) -> int: + manifests = discover_manifests(root) + stamped: list[str] = [] + upgraded: list[str] = [] + pending: list[str] = [] + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + if not proof.is_file(): + stamp_manifest(root, manifest, ots_bin) + stamped.append(manifest.name) + pending.append(manifest.name) + continue + if proof_is_complete(proof, ots_bin): + continue + upgrade_proof(proof, ots_bin) + if proof_is_complete(proof, ots_bin): + upgraded.append(manifest.name) + else: + pending.append(manifest.name) + print( + f"ots anchor run: {len(manifests)} manifests, " + f"stamped {len(stamped)}, upgraded {len(upgraded)}, " + f"still pending {len(pending)}" + ) + for name in stamped: + print(f" stamped {name}") + for name in upgraded: + print(f" upgraded {name}") + return 0 + + +def command_verify( + root: pathlib.Path, ots_bin: list[str], *, require_bitcoin: bool +) -> int: + manifests = discover_manifests(root) + failures: list[str] = [] + pending_count = 0 + bitcoin_count = 0 + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + if not proof.is_file(): + failures.append(f"{manifest.name}: no OpenTimestamps proof") + continue + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + failures.append(f"{manifest.name}: proof does not match manifest bytes") + elif state == "pending": + pending_count += 1 + if require_bitcoin: + failures.append(f"{manifest.name}: attestation not yet in Bitcoin") + else: + bitcoin_count += 1 + print( + f"ots anchor verify: {len(manifests)} manifests, " + f"{bitcoin_count} with Bitcoin attestations, {pending_count} pending" + ) + for failure in failures: + print(f" FAIL {failure}", file=sys.stderr) + if failures: + return 1 + print("every release manifest has an OpenTimestamps proof bound to its exact bytes") + return 0 + + +def command_status(root: pathlib.Path, ots_bin: list[str]) -> int: + manifests = discover_manifests(root) + for manifest in manifests: + proof = proof_path(root, manifest) + if not proof.is_file(): + print(f"{manifest.name}: unanchored") + continue + state = classify_proof(manifest, proof, ots_bin) + label = { + "bitcoin": "bitcoin attestation", + "pending": "pending calendar attestation", + "mismatch": "MISMATCH", + }[state] + print(f"{manifest.name}: {label}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="anchor witnessed release manifests via OpenTimestamps" + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--root", + type=pathlib.Path, + default=ROOT, + help=argparse.SUPPRESS, + ) + common.add_argument( + "--ots-bin", + default="ots", + help="ots invocation, shell-split (default: %(default)s)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser( + "run", parents=[common], help="stamp missing proofs, upgrade pending" + ) + verify_parser = subparsers.add_parser( + "verify", + parents=[common], + help="check every proof against current manifest bytes", + ) + verify_parser.add_argument( + "--require-bitcoin", + action="store_true", + help="fail while any attestation is still pending", + ) + subparsers.add_parser( + "status", parents=[common], help="list proofs and their state" + ) + args = parser.parse_args(argv) + + root = args.root.resolve() + ots_bin = shlex.split(args.ots_bin) + try: + if args.command == "run": + return command_run(root, ots_bin) + if args.command == "verify": + return command_verify(root, ots_bin, require_bitcoin=args.require_bitcoin) + return command_status(root, ots_bin) + except AnchorError as exc: + print(f"ots anchor failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py new file mode 100644 index 00000000..d55da061 --- /dev/null +++ b/tests/test_ots_anchor.py @@ -0,0 +1,220 @@ +"""Tests for scripts/ots_anchor.py. + +The suite never contacts calendar servers or Bitcoin: a fake ``ots`` +executable reproduces the observed opentimestamps-client 0.7.2 output +contract (stamp/upgrade/info/verify), and every invocation is logged so the +tests can assert which operations ran. +""" + +from __future__ import annotations + +import hashlib +import json +import shlex +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +sys.path.insert(0, str(ROOT / "scripts")) + +import ots_anchor # noqa: E402 + +FAKE_OTS = r""" +import hashlib +import json +import os +import pathlib +import sys + +LOG = pathlib.Path(os.environ["FAKE_OTS_LOG"]) + + +def log(entry): + with LOG.open("a", encoding="utf-8") as handle: + handle.write(entry + "\n") + + +def read_proof(path): + return json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + + +def write_proof(path, payload): + pathlib.Path(path).write_text(json.dumps(payload), encoding="utf-8") + + +def main(): + arguments = [a for a in sys.argv[1:] if a != "--no-bitcoin"] + command = arguments[0] + log(command) + if command == "stamp": + target = pathlib.Path(arguments[1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + write_proof( + str(target) + ".ots", {"digest": digest, "state": "pending"} + ) + print("Submitting to remote calendar https://fake.calendar") + return 0 + if command == "info": + proof = read_proof(arguments[1]) + if proof["state"] == "bitcoin": + print("verify BitcoinBlockHeaderAttestation(963213)") + else: + print("verify PendingAttestation('https://fake.calendar')") + return 0 + if command == "upgrade": + path = pathlib.Path(arguments[1]) + proof = read_proof(path) + if os.environ.get("FAKE_OTS_UPGRADE") == "success": + proof["state"] = "bitcoin" + write_proof(path, proof) + pathlib.Path(str(path) + ".bak").write_text( + "backup", encoding="utf-8" + ) + print("Success! Timestamp complete") + return 0 + print("Failed! Timestamp not complete") + return 1 + if command == "verify": + target = pathlib.Path(arguments[arguments.index("-f") + 1]) + proof = read_proof(arguments[-1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + if digest != proof["digest"]: + print("File does not match original!") + return 1 + if proof["state"] == "bitcoin": + print( + "To verify manually, check that Bitcoin block 963213 " + "has merkleroot aa" + ) + return 1 + print("Pending confirmation in Bitcoin blockchain") + return 1 + raise SystemExit(f"unexpected fake ots command: {command}") + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + + +def make_manifest(directory: Path, index: int, payload: bytes) -> Path: + digest = hashlib.sha256(payload).hexdigest() + path = directory / f"{index:04d}-{digest[:16]}.json" + path.write_bytes(payload) + return path + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + manifest_dir = tmp_path / "releases" / "manifests" + manifest_dir.mkdir(parents=True) + manifests = [ + make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n'), + make_manifest(manifest_dir, 1, b'{"releaseIndex": 1}\n'), + ] + fake = tmp_path / "fake_ots.py" + fake.write_text(FAKE_OTS, encoding="utf-8") + log = tmp_path / "ots-invocations.log" + log.touch() + monkeypatch.setenv("FAKE_OTS_LOG", str(log)) + monkeypatch.setenv("FAKE_OTS_UPGRADE", "pending") + ots_bin = f"{shlex.quote(sys.executable)} {shlex.quote(str(fake))}" + return { + "root": tmp_path, + "manifest_dir": manifest_dir, + "manifests": manifests, + "ots_bin": ots_bin, + "log": log, + } + + +def run_cli(repo: dict, *arguments: str) -> int: + return ots_anchor.main( + [*arguments, "--root", str(repo["root"]), "--ots-bin", repo["ots_bin"]] + ) + + +def logged_commands(repo: dict) -> list[str]: + return repo["log"].read_text(encoding="utf-8").split() + + +def test_run_stamps_every_manifest_into_ots_dir(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proofs = sorted((repo["root"] / "ots").iterdir()) + assert [p.name for p in proofs] == [m.name + ".ots" for m in repo["manifests"]] + for manifest, proof in zip(repo["manifests"], proofs): + payload = json.loads(proof.read_text(encoding="utf-8")) + assert payload["digest"] == hashlib.sha256(manifest.read_bytes()).hexdigest() + + +def test_run_never_writes_into_releases(repo: dict) -> None: + before = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert run_cli(repo, "run") == 0 + after = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert before == after + + +def test_run_is_idempotent_and_upgrades_pending_proofs( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + + # Second run: calendars still pending — no new stamps, upgrade attempted. + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + assert logged_commands(repo).count("upgrade") == 2 + + # Third run: attestations land — proofs upgraded in place, .bak removed. + monkeypatch.setenv("FAKE_OTS_UPGRADE", "success") + assert run_cli(repo, "run") == 0 + for manifest in repo["manifests"]: + proof = repo["root"] / "ots" / f"{manifest.name}.ots" + assert json.loads(proof.read_text(encoding="utf-8"))["state"] == ("bitcoin") + assert not proof.with_name(proof.name + ".bak").exists() + + # Fourth run: complete proofs are left untouched (no further upgrades). + upgrades_before = logged_commands(repo).count("upgrade") + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("upgrade") == upgrades_before + + +def test_run_refuses_manifest_contradicting_its_filename(repo: dict) -> None: + rogue = repo["manifest_dir"] / f"0002-{'0' * 16}.json" + rogue.write_bytes(b'{"releaseIndex": 2}\n') + assert run_cli(repo, "run") == 1 + + +def test_verify_passes_with_pending_proofs_by_default(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "verify") == 0 + assert run_cli(repo, "verify", "--require-bitcoin") == 1 + + +def test_verify_fails_on_missing_proof(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + (repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots").unlink() + assert run_cli(repo, "verify") == 1 + + +def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][1].name}.ots" + payload = json.loads(proof.read_text(encoding="utf-8")) + payload["digest"] = "ab" * 32 + proof.write_text(json.dumps(payload), encoding="utf-8") + assert run_cli(repo, "verify") == 1 + + +def test_status_reports_each_state(repo: dict, capsys) -> None: + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("unanchored") == 2 + + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("pending calendar attestation") == 2