# -*- coding: utf-8 -*-
"""
commitment_gate.py — the frozen record must still match what was timestamped.

Round-six red team edited a registered prediction's threshold and confidence in
PREDICTIONS_v2, rebuilt, and got CHECK PASSED with sha256sum -c all-OK. The reason
is structural and it was my design: the manifest is regenerated from live files at
every build, so it can never disagree with them. An integrity check that cannot
fail is not an integrity check.

timestamp/COMMITMENT.txt lists the frozen record with the digests it had when it was
submitted to the OpenTimestamps Bitcoin calendars. That file is external evidence —
it cannot be regenerated, because regenerating it breaks its own timestamp. This gate
compares every listed digest against the live file.

Editing a frozen document now requires either leaving this gate failing, or destroying
the timestamp that proves the record is what it was. That is the property the register
claimed and did not have.

The attestation check needs NOTHING but Python: the attested digest is read out of the
.ots proof directly. `ots` is used, when present, only to corroborate that reading and
to report whether the proof has reached a Bitcoin block. Its absence is printed, never
silently skipped.

Run: python3 scripts/commitment_gate.py
"""
import hashlib, os, re, subprocess, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, ".."))
# repo layout puts it in timestamp/; the published bundle is flat
# Every commitment file, checked. New record documents get a NEW commitment with a new
# name — editing a timestamped one destroys the attestation that makes it worth anything.
NAMES = ["COMMITMENT.txt", "COMMITMENT-2.txt", "COMMITMENT-3.txt", "COMMITMENT-4.txt"]
_CANDIDATES = [os.path.join(ROOT, "timestamp", "COMMITMENT.txt"),
               os.path.join(HERE, "COMMITMENT.txt")]
COMMIT = next((c for c in _CANDIDATES if os.path.exists(c)), _CANDIDATES[0])
BASE = os.path.dirname(COMMIT) if os.path.basename(os.path.dirname(COMMIT)) != "timestamp" else ROOT
OTS = COMMIT + ".ots"


# READING a proof's header is not verifying it. The first version of this gate
# established "the attestation covers this file" from the 32 bytes after the magic --
# and a red-teamer patched those 32 bytes with one `sed`, rewrote a registered
# prediction's threshold, and got BUILD PASSED with this gate printing a fabricated
# attestation line. `ots info` reads the same header, so it corroborated the forgery.
#
# ots_verify.py walks the merkle path instead, from the file's own sha256 to the merkle
# root of the Bitcoin block the proof names, and checks that root against two independent
# public explorers that must agree with each other. Forging the label is one sed; forging
# the path means finding a preimage chain that lands on a real block's merkle root.
# The same attack now fails: "FORGED OR CORRUPT: the path walks to 84c1ae..., but block
# 964701 has merkle root 1dd19a...".
import ots_verify



def anchors():
    """What each commitment is REQUIRED to prove, from timestamp/ANCHORS.txt."""
    for base in (os.path.join(ROOT, "timestamp"), HERE):
        p = os.path.join(base, "ANCHORS.txt")
        if os.path.exists(p):
            out = {}
            for line in open(p, encoding="utf-8"):
                m = re.match(r"^(\S+\.txt)\s*\|\s*(\w+)\s*\|\s*(\S+)\s*\|\s*(\S+)?", line)
                if m:
                    blk = None if m.group(3) == "\u2014" else int(m.group(3))
                    out[m.group(1)] = (m.group(2), blk, None)
            return out
    raise SystemExit("COMMITMENT GATE FAILED — timestamp/ANCHORS.txt is missing. Without it "
                     "the gate accepts any valid proof, including one made moments ago over "
                     "rewritten content.")


# The FROZEN RECORD — registers, specifications, data, validation. These never change,
# so their commitments must be ANCHORED and stay anchored: a red-teamer showed that
# allowing any of them to sit in a "pending" state lets an attacker rewrite the frozen
# file, re-point the digest, forge a pending proof, downgrade the ANCHORS pin, and ship.
# For these, pending is not a state that exists — the answer is always "confirmed".
RECORD_COMMITMENTS = {"COMMITMENT.txt", "COMMITMENT-2.txt", "COMMITMENT-4.txt"}
# The CODE commitment re-anchors every release to a recent block, so it is legitimately
# pending in the window between a code change and its confirmation. A build in that window
# is a DEV build, not a release; the deploy step refuses to mark a DEV build as published.
CODE_COMMITMENT = "COMMITMENT-3.txt"

# The freeze deadline. The frozen record was anchored on 2026-08-30; nothing in it may be
# anchored to a block mined after this instant. This is the cryptographic lock a red-teamer's
# attack turned on: rewritten content can only be anchored to a block mined AFTER the rewrite,
# so a record commitment whose block postdates the freeze is a re-stamp, whatever ANCHORS says.
# ANCHORS.txt is a movable convenience index; the block time is not movable.
FREEZE_DEADLINE = 1788134400   # 2026-08-31 00:00:00 UTC (after the last record anchor, 08-30 21:35)


def _check_one(commit, ots_path):
    want = hashlib.sha256(open(commit, "rb").read()).hexdigest()
    name = os.path.basename(commit)
    is_record = name in RECORD_COMMITMENTS

    # Walk the proof to the chain. A PROVEN failure -- forged path, or the digest not
    # matching the file -- always stops the build.
    offline = os.environ.get("COMMITMENT_GATE_OFFLINE") == "1"
    ok, lines, st, block_time = ots_verify.verify(ots_path, offline=offline)
    if not ok:
        raise SystemExit("COMMITMENT GATE FAILED — the attestation for %s does not verify:\n"
                         % name + "\n".join("    " + l for l in lines) +
                         "\n  A frozen document is not edited, and a proof is not a label. "
                         "Issue a new version with a new name and a new commitment.")

    want_state, want_block, want_time = anchors().get(name, ("unpinned", None, None))
    if want_state == "unpinned":
        raise SystemExit("COMMITMENT GATE FAILED — %s is not listed in ANCHORS.txt." % name)
    got_block = next((d for k, d, _ in ots_verify.parse(ots_path)[2] if k == "bitcoin"), None)

    # A frozen-record commitment is held to the strong standard: confirmed on-chain, its
    # merkle path walked (never merely offline-accepted), its block matching the pin, and
    # its block mined on or before the freeze. Pending is not a permitted state for it.
    if is_record:
        if st != "confirmed":
            raise SystemExit(
                "COMMITMENT GATE FAILED — %s is a frozen-record commitment and did not "
                "verify to a confirmed Bitcoin block (state: %s). A record commitment is "
                "never pending and never offline-accepted; %s"
                % (name, st, "the network was unreachable — records cannot be checked "
                   "offline" if offline or st == "unchecked" else
                   "this looks like a forged or re-stamped proof."))
        if got_block != want_block:
            raise SystemExit("COMMITMENT GATE FAILED — %s is pinned to block %s and this "
                             "proof anchors at %r. A re-stamp over rewritten content lands "
                             "in a later block." % (name, want_block, got_block))
        if block_time and block_time > FREEZE_DEADLINE:
            raise SystemExit(
                "COMMITMENT GATE FAILED — %s anchors to block %s, mined AFTER the freeze "
                "deadline. Rewritten content can only be anchored to a block that postdates "
                "the rewrite, so a frozen record in a post-freeze block is a re-stamp, "
                "whatever ANCHORS.txt says. This is the check the movable pin cannot dodge."
                % (name, got_block))
        state = "ANCHORED — merkle path verified, block %s, mined on or before the freeze" % got_block
    else:
        # The code commitment: confirmed is a release; pending is a DEV build, reported
        # loudly so it can never be mistaken for a published one. A forged pending proof
        # buys the attacker only a DEV build, and a real anchored one is a permanent public
        # timestamp of the tampered scripts -- the honest ceiling for a code commitment.
        if st == "confirmed":
            if got_block != want_block:
                raise SystemExit("COMMITMENT GATE FAILED — %s anchors at %r, not its pinned "
                                 "block %s." % (name, got_block, want_block))
            state = "ANCHORED (code release) — block %s" % got_block
        elif st in ("pending", "unchecked"):
            state = "PENDING — DEV BUILD, code not yet anchored; not a published release"
            _mark_dev_build(name, st)
        else:
            raise SystemExit("COMMITMENT GATE FAILED — %s did not verify (state: %s)."
                             % (name, st))
    detail = lines

    text = open(commit, encoding="utf-8").read()
    rows = re.findall(r"^([0-9a-f]{64})  (\S+)$", text, re.M)
    if not rows:
        raise SystemExit(f"COMMITMENT GATE FAILED — {name} lists no digests. An empty "
                         "commitment file passes every check and freezes nothing.")
    drift, rerouted = [], []
    for w, rel in rows:
        p = os.path.join(BASE, rel)
        if not os.path.exists(p):
            # The published bundle is flat, so public_data/x.csv lives beside the scripts.
            # This fallback is necessary and it is also a way to check the WRONG file
            # without noticing, so every use of it is reported rather than assumed benign.
            alt = os.path.join(BASE, os.path.basename(rel))
            if os.path.exists(alt):
                p, _ = alt, rerouted.append((rel, os.path.basename(rel)))
        if not os.path.exists(p):
            drift.append((rel, "MISSING", w)); continue
        g = hashlib.sha256(open(p, "rb").read()).hexdigest()
        if g != w:
            drift.append((rel, g, w))
    return rows, drift, state, rerouted, detail


_DEV_FLAG = os.path.join(ROOT, "timestamp", ".dev_build")


def _mark_dev_build(name, st):
    # FAIL CLOSED. If the DEV flag cannot be written, is_release() (which reads the flag's
    # ABSENCE as "release") would let a pending-code build present as a published release —
    # exactly what a red-teamer tried by making the directory unwritable. A build that
    # cannot record that it is a DEV build must not proceed, rather than proceed looking
    # like a release.
    try:
        os.makedirs(os.path.dirname(_DEV_FLAG), exist_ok=True)   # a missing dir is not an attack
        open(_DEV_FLAG, "w").write("%s is %s — not a published release\n" % (name, st))
    except OSError as e:
        # The directory exists (or was just created) and the write STILL failed — the flag
        # was actively suppressed (e.g. an unwritable timestamp/). is_release() reads the
        # flag's ABSENCE as "release", so a suppressed write could pass a pending-code build
        # off as published. Fail closed rather than proceed looking like a release.
        raise SystemExit("COMMITMENT GATE FAILED — %s is pending (a DEV build), but the "
                         "DEV-build marker could not be written (%s). Refusing to continue, "
                         "because a build that cannot mark itself DEV could be mistaken for a "
                         "release." % (name, e))


def is_release():
    """True iff every commitment, code included, is anchored: a publishable release.

    The deploy step calls this. A DEV build (code commitment pending) still runs and
    still writes a deploy tree for iteration, but it is not a release, and the build
    labels it so a pending-code artifact can never be mistaken for a published one."""
    return not os.path.exists(_DEV_FLAG)


def main():
    try:
        os.remove(_DEV_FLAG)
    except OSError:
        pass
    found = []
    for name in NAMES:
        for base in (os.path.join(ROOT, "timestamp"), HERE):
            c = os.path.join(base, name)
            if os.path.exists(c):
                found.append(c)
                break
    if not found:
        raise SystemExit("commitment gate: no COMMITMENT file — the frozen record has no "
                         "external anchor")
    total, bad, states = 0, [], []
    for c in found:
        if not os.path.exists(c + ".ots"):
            raise SystemExit(f"commitment gate: {os.path.basename(c)}.ots is missing — the "
                             "commitment exists but nothing attests to when")
        rows, drift, state, rerouted, detail = _check_one(c, c + ".ots")
        total += len(rows)
        states.append((os.path.basename(c), state, rerouted, detail))
        bad += [(os.path.basename(c),) + d for d in drift]
    if bad:
        print("COMMITMENT GATE FAILED — a timestamped file has changed:\n")
        for src, rel, got, want in bad:
            print(f"  {rel}  (in {src})\n      timestamped {want}\n      now         {got}\n")
        if any(src == "COMMITMENT-3.txt" for src, *_ in bad):
            print("COMMITMENT-3 is the CODE commitment, and code is meant to change. This is")
            print("not an accusation — it is the release step:")
            print("    python3 scripts/make_code_commitment.py")
            print("    ots stamp timestamp/COMMITMENT-3.txt")
            print("Then add its block to ANCHORS.txt when it confirms. A release is the")
            print("moment the code is pinned; between changes and a re-stamp, this fails.\n")
        print("These files are covered by the attestations beside them. If the change is")
        print("legitimate, it is a NEW version with a new name and a new commitment — not an")
        print("edit to a frozen one.")
        raise SystemExit(1)
    print(f"    commitment gate: {total} frozen files across {len(found)} commitments match")
    for n, st, rr, det in states:
        print(f"      {n}: {st}")
        for l in det:
            print(f"        {l}")
        for rel, used in rr:
            print(f"        note: {rel} not at that path; matched {used} beside the scripts "
                  "(flat-bundle layout)")


if __name__ == "__main__":
    main()
