#!/usr/bin/env python3 """treewatch.py — a standing OUTSIDE check on a published tree, between builds. treewatch.py snap take a snapshot treewatch.py diff what changed between two treewatch.py audit [--sample N] verify declared hashes now WHY THIS EXISTS. A publish-time gate can refuse to serve a tree whose digest does not match. It cannot see anything that happens AFTER the copy lands: a byte edited on disk, a file added out of band, a host serving a stale revision. That residual is what an outsider is for, and it is checkable with nothing but HTTP. WHAT A SNAPSHOT IS. The manifest's own declared list (path, sha256, bytes) plus the manifest's digests and build stamp. It is small, so keeping many is cheap, and it is the publisher's own claim — the diff therefore shows how the CLAIM moved, which is the honest thing to show when you have not downloaded the tree. WHAT `audit` ADDS. It downloads files and compares them against the declared hashes, so it checks the SERVED BYTES, not the claim. Sampling is allowed and is reported as a sample: "12 of 1019 verified" is a true sentence, "verified" alone is not. BUILD GUARD. Every run records `built` before and after its own work. If the build moved underneath, the run says so instead of quietly mixing two trees. WHAT THIS CANNOT DO, stated so nobody upgrades it by reading: it cannot see a file that is neither in the list nor reachable by a link. Completeness of the served tree against the disk is the owner's claim; an outsider can only check the list against itself and against the bytes it can fetch. """ import hashlib, json, sys, time, urllib.request from concurrent.futures import ThreadPoolExecutor UA = "treewatch/0.1 (external archive check)" def get(url): r = urllib.request.Request(url) r.add_header("User-Agent", UA) with urllib.request.urlopen(r, timeout=60) as f: return f.read() def manifest(url): return json.loads(get(url)) def cmd_snap(argv): url, out = argv[0], argv[1] m = manifest(url) snap = { "taken_at": int(time.time()), "manifest_url": url, "built": m.get("built"), "content_digest_sha256": m.get("content_digest_sha256"), "manifest_digest": m.get("manifest_digest"), "file_count": m.get("file_count"), "files": {f["path"]: [f["sha256"], f["bytes"]] for f in m.get("files", [])}, } if not snap["files"]: print("WARNING: this manifest publishes no file list; the snapshot is a claim " "about digests only, and a diff cannot name a single changed path.") json.dump(snap, open(out, "w"), indent=0) print(f"snapshot {out}: built {snap['built']} {len(snap['files'])} files " f"content_digest {str(snap['content_digest_sha256'])[:16]}") def cmd_diff(argv): a, b = json.load(open(argv[0])), json.load(open(argv[1])) fa, fb = a["files"], b["files"] added = sorted(set(fb) - set(fa)) gone = sorted(set(fa) - set(fb)) changed = sorted(p for p in set(fa) & set(fb) if fa[p][0] != fb[p][0]) print(f"built {a['built']} -> {b['built']} " f"({b['taken_at'] - a['taken_at']}s between snapshots)") print(f"files {len(fa)} -> {len(fb)} added {len(added)} removed {len(gone)} " f"content changed {len(changed)}") for tag, lst in (("+", added), ("-", gone), ("~", changed)): for p in lst[:40]: print(f" {tag} {p}") if len(lst) > 40: print(f" {tag} … and {len(lst) - 40} more") if a["content_digest_sha256"] == b["content_digest_sha256"] and (added or gone or changed): print("ALARM: the content digest did not move but the file list did. One of the two " "is wrong, and the digest is the one that is supposed to be impossible to fake.") if not (added or gone or changed): print("no path-level change. A rebuild with no diff is normal; a diff with no " "rebuild is not.") def cmd_audit(argv): url = argv[0] n = int(dict(x.split("=", 1) for x in argv[1:] if "=" in x).get("--sample", "0")) m = manifest(url) files = m.get("files") or [] if not files: sys.exit("no file list in this manifest: nothing to audit from outside") base = m.get("base_url") or url.rsplit("/", 1)[0] picked = files if not n else files[:: max(1, len(files) // n)][:n] ok = bad = 0 bads = [] def one(f): try: return f, get(base + f["path"]) except Exception as e: return f, e with ThreadPoolExecutor(16) as ex: for f, b in ex.map(one, picked): if isinstance(b, Exception): bad += 1 bads.append((f["path"], f"FETCH {type(b).__name__}")) continue h = hashlib.sha256(b).hexdigest() if h == f["sha256"] and len(b) == f["bytes"]: ok += 1 else: bad += 1 bads.append((f["path"], f"declared {f['sha256'][:12]} got {h[:12]}")) after = manifest(url).get("built") print(f"audited {ok + bad} of {len(files)} declared files: {ok} match, {bad} differ") for p, why in bads[:20]: print(f" ! {p} {why}") print(f"built {m.get('built')} -> {after} " f"{'STABLE' if m.get('built') == after else 'REBUILT MID-RUN — rerun, this result mixes two trees'}") if n: print(f"this was a SAMPLE of {len(picked)}; it says nothing about the other " f"{len(files) - len(picked)} files") CMDS = {"snap": cmd_snap, "diff": cmd_diff, "audit": cmd_audit} if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in CMDS: print(__doc__) sys.exit(0) CMDS[sys.argv[1]](sys.argv[2:])