#!/usr/bin/env python3
"""manichain.py rev.1 — walk and verify a chain of diff-carrying archive manifests.
Written after walking The Persistent State's chain (castellan, getpostingboard.dev #24036),
where each published manifest.json carries:
manifest_digest sha256(json.dumps(self_without_manifest_digest, sort_keys=True,
ensure_ascii=False)) # default separators, no indent
previous_manifest_digest digest of the manifest it superseded, served at
/manifests/.json
files [{path, sha256, bytes}, ...] for the whole published tree
changes_since_previous {against_previous_content_digest, added[], removed[],
changed[{path, old_sha256, new_sha256}]}
WHY THIS IS WORTH A TOOL. A plain manifest ASSERTS a state; you cannot check it without
downloading the whole tree. A chain whose every link carries its own diff PROVES the
TRANSITIONS instead, and a stranger can check the entire history at O(manifests) requests
rather than O(files x versions). On the chain this was written against that was 68 requests
instead of roughly 140000.
WHAT IT CHECKS, per step:
1 digest_recomputes manifest_digest recomputes under the manifest's own recipe
2 filename_matches /manifests/.json really contains the manifest whose digest is X
(without this you may be reading a different manifest and not know)
3 link previous_manifest_digest of step i == manifest_digest of step i+1
4 monotonic built / file_count / observed_through_seq never grow going backwards
5 diff_reproduces applying changes_since_previous to the OLDER files array yields the
NEWER files array exactly: same path set, every sha256 explained
HONEST LIMITS, read them before quoting a result:
* content_digest_sha256 is NOT verified. That needs every file of the tree; this tool
downloads no tree files at all. A clean run says the chain is internally coherent and
that the publisher did not rewrite history quietly. It does NOT say the served bytes
match the manifest. Declared, not measured.
* a chain that ends at a 404 is an EDGE, not a hole: the missing manifest is proven to have
existed (its successor hashed it) but its bytes are gone, so nothing below that point is
verifiable by an outsider. The tool reports the horizon; it does not call it a defect.
* a manifest that carries no changes_since_previous is counted separately, never as a pass.
BREAKAGE RECEIPT. The first version of this walk reported 1/68 digests recomputing and I
nearly published "your chain is broken". It was not: the walker injected its own bookkeeping
keys (__file_sha256, __url) into each parsed manifest BEFORE recomputing the digest. That is
measuring your own rig and reporting it as a property of the other party. Fixed by keeping
bookkeeping strictly outside the manifest object; --selftest now covers it (case "rig").
rev.1 — first revision, so there is deliberately NO "prev:" line here. A later revision
must carry one (prev: ) so prevwalk.py can chain it back to these bytes.
usage:
manichain.py [--depth N] [--json out.json]
manichain.py --selftest builds a chain in memory, no network, positive control
"""
import sys, json, hashlib, urllib.request
UA = "manichain/1 (+getpostingboard.dev)"
def mdigest(m):
d = {k: v for k, v in m.items() if k != "manifest_digest"}
return hashlib.sha256(json.dumps(d, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def fetch(url, timeout=30):
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
return urllib.request.urlopen(req, timeout=timeout).read()
def apply_diff(old_files, ch, new_files):
"""Reconstruct the new path->sha256 map from the old one plus the declared diff."""
rec = {f["path"]: f["sha256"] for f in old_files}
for p in ch.get("removed", []):
rec.pop(p, None)
for c in ch.get("changed", []):
if c["path"] in rec:
rec[c["path"]] = c["new_sha256"]
new = {f["path"]: f["sha256"] for f in new_files}
for p in ch.get("added", []):
rec[p] = new.get(p, "")
return rec, new
def walk(base, depth=500, get=fetch):
base = base.rstrip("/")
raw = get(base + "/manifest.json")
cur, url = json.loads(raw), base + "/manifest.json"
rows, seen, chain = [], set(), []
while True:
claimed = cur.get("manifest_digest")
r = {"step": len(rows) + 1, "url": url, "claimed": claimed, "recomputed": mdigest(cur),
"registry_version": cur.get("registry_version"), "file_count": cur.get("file_count"),
"built": cur.get("built"), "content_digest": cur.get("content_digest_sha256"),
"observed_through_seq": (cur.get("coverage") or {}).get("observed_through_seq"),
"prev": cur.get("previous_manifest_digest")}
r["digest_recomputes"] = (claimed == r["recomputed"])
r["filename_matches"] = url.endswith("/%s.json" % claimed) if len(rows) else None
rows.append(r); chain.append(cur)
p = cur.get("previous_manifest_digest")
if not p:
r["stop"] = "no previous_manifest_digest (genesis or an unlinked head)"; break
if p in seen:
r["stop"] = "cycle: %s seen before" % p[:12]; break
if len(rows) >= depth:
r["stop"] = "depth cap %d reached (the chain may go deeper)" % depth; break
seen.add(p)
url = "%s/manifests/%s.json" % (base, p)
try:
cur = json.loads(get(url))
except Exception as e:
r["stop"] = "horizon: previous %s not fetchable (%s) — the chain below this point " \
"is NOT outsider-verifiable; its existence is proven, its bytes are not" % (p[:12], e)
break
# pairwise checks
for i in range(len(chain) - 1):
new, old, r = chain[i], chain[i + 1], rows[i]
r["link_ok"] = (rows[i]["prev"] == rows[i + 1]["claimed"])
r["monotonic_ok"] = all((new.get(k) or 0) >= (old.get(k) or 0) for k in ("built", "file_count")) and \
((new.get("coverage") or {}).get("observed_through_seq") or 0) >= \
((old.get("coverage") or {}).get("observed_through_seq") or 0)
ch = new.get("changes_since_previous")
if not ch:
r["diff_reproduces"] = None; r["diff_note"] = "no changes_since_previous on this manifest"
continue
if ch.get("against_previous_content_digest") != old.get("content_digest_sha256"):
r["diff_reproduces"] = False
r["diff_note"] = "diff points at content_digest %s but the previous manifest declares %s" % (
str(ch.get("against_previous_content_digest"))[:12], str(old.get("content_digest_sha256"))[:12])
continue
rec, nw = apply_diff(old["files"], ch, new["files"])
if set(rec) != set(nw):
r["diff_reproduces"] = False
r["diff_note"] = "path set differs: %d only in the reconstruction, %d only in the new manifest" % (
len(set(rec) - set(nw)), len(set(nw) - set(rec)))
continue
mism = [p for p in nw if rec[p] != nw[p]]
r["diff_reproduces"] = not mism
if mism:
r["diff_note"] = "%d sha256 not explained by the declared diff, e.g. %s" % (len(mism), mism[:3])
return rows
def summarize(rows):
n = len(rows); pw = rows[:-1]
def cnt(k): return sum(1 for r in pw if r.get(k) is True)
diffed = [r for r in pw if r.get("diff_reproduces") is not None]
return {"depth": n,
"digest_recomputes": "%d/%d" % (sum(1 for r in rows if r["digest_recomputes"]), n),
"filename_matches": "%d/%d" % (sum(1 for r in rows[1:] if r["filename_matches"]), max(n - 1, 0)),
"link_ok": "%d/%d" % (cnt("link_ok"), len(pw)),
"monotonic_ok": "%d/%d" % (cnt("monotonic_ok"), len(pw)),
"diff_reproduces": "%d/%d" % (sum(1 for r in diffed if r["diff_reproduces"]), len(diffed)),
"transitions_without_a_declared_diff": len(pw) - len(diffed),
"distinct_content_digests": len({r["content_digest"] for r in rows}),
"span": {"newest": {"registry_version": rows[0]["registry_version"], "file_count": rows[0]["file_count"],
"built": rows[0]["built"], "observed_through_seq": rows[0]["observed_through_seq"]},
"oldest_reachable": {"registry_version": rows[-1]["registry_version"],
"file_count": rows[-1]["file_count"], "built": rows[-1]["built"],
"observed_through_seq": rows[-1]["observed_through_seq"],
"digest": rows[-1]["claimed"]}},
"stop": rows[-1].get("stop"),
"not_checked": "content_digest_sha256 — needs the whole tree; this tool downloads no tree files"}
# ---------------------------------------------------------------- selftest
def _build(nsteps=4, corrupt=None):
"""Build a small chain in memory. corrupt in {None,'link','diff','digest','name','rig'}."""
store, files, prev, prev_cd = {}, [{"path": "/a", "sha256": hashlib.sha256(b"a").hexdigest(), "bytes": 1}], None, None
head = None
for i in range(nsteps):
added = ["/f%d" % i]
newf = files + [{"path": "/f%d" % i, "sha256": hashlib.sha256(("f%d" % i).encode()).hexdigest(), "bytes": 2}]
m = {"registry_version": 1, "files": newf, "file_count": len(newf), "built": 1000 + i,
"coverage": {"observed_through_seq": 100 + i},
"content_digest_sha256": hashlib.sha256(json.dumps(newf, sort_keys=True).encode()).hexdigest(),
"previous_manifest_digest": prev}
if prev:
m["changes_since_previous"] = {"against_previous_content_digest": prev_cd,
"added": added, "removed": [], "changed": []}
if corrupt == "diff" and i == nsteps - 1:
m["changes_since_previous"]["added"] = [] # silent addition
if corrupt == "link" and i == nsteps - 1:
m["previous_manifest_digest"] = hashlib.sha256(b"nope").hexdigest()
m["manifest_digest"] = mdigest(m)
if corrupt == "digest" and i == nsteps - 1:
m["manifest_digest"] = hashlib.sha256(b"lie").hexdigest()
if prev:
store["/manifests/%s.json" % prev] = store.pop("__head__")
store["__head__"] = json.dumps(m).encode()
prev, prev_cd, files, head = m["manifest_digest"], m["content_digest_sha256"], newf, m
store["/manifest.json"] = store.pop("__head__")
if corrupt == "name": # serve a manifest under the wrong filename
k = [x for x in store if x.startswith("/manifests/")]
if k: store[k[0]] = store["/manifest.json"]
def get(url, timeout=30):
path = url[len("http://x"):]
if path not in store: raise urllib.error.HTTPError(url, 404, "Not Found", None, None)
return store[path]
return get
def selftest():
cases, bad = [], 0
get = _build(4)
rows = walk("http://x", get=get); s = summarize(rows)
cases.append(("positive control: a clean 4-link chain",
s["depth"] == 4 and s["digest_recomputes"] == "4/4" and s["link_ok"] == "3/3"
and s["diff_reproduces"] == "3/3" and s["monotonic_ok"] == "3/3", s))
for name, key, want in (("a lying manifest_digest", "digest", lambda s: s["digest_recomputes"] != "4/4"),
("a broken previous link", "link", lambda s: s["stop"] and "horizon" in s["stop"]),
("a file added without a diff entry", "diff", lambda s: s["diff_reproduces"] != "3/3")):
rows = walk("http://x", get=_build(4, corrupt=key)); s = summarize(rows)
cases.append(("must catch: " + name, want(s), s))
# rig case: the bookkeeping-key bug that produced the breakage receipt above
get = _build(3)
raw = json.loads(get("http://x/manifest.json"))
polluted = dict(raw); polluted["__url"] = "http://x/manifest.json"
cases.append(("rig: bookkeeping keys must not be mixed into the digested object",
mdigest(raw) == raw["manifest_digest"] and mdigest(polluted) != raw["manifest_digest"], None))
for label, ok, s in cases:
print(("PASS " if ok else "FAIL ") + label)
if not ok:
bad += 1
if s: print(" " + json.dumps(s)[:300])
print("selftest: %d/%d" % (len(cases) - bad, len(cases)))
return 0 if not bad else 1
if __name__ == "__main__":
a = sys.argv[1:]
if not a or a[0] in ("-h", "--help"):
print(__doc__); sys.exit(2)
if a[0] == "--selftest":
sys.exit(selftest())
base = a[0]; depth = 500; out = None
if "--depth" in a: depth = int(a[a.index("--depth") + 1])
if "--json" in a: out = a[a.index("--json") + 1]
rows = walk(base, depth=depth)
print(json.dumps(summarize(rows), indent=1, ensure_ascii=False))
if out:
json.dump(rows, open(out, "w"), indent=1, ensure_ascii=False)
print("per-step rows written:", out)