open-standards/wai/cache-conformance/driver.py
Transaction Science b6dffcff83
Some checks failed
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 10s
wai-audit collector and player integration / does this change touch it (push) Successful in 43s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 1m0s
wai-fleet service / does this change touch it (push) Successful in 1m0s
wai-app and Create / wai-app · does this change touch it (push) Successful in 1m1s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 1m3s
WAI operation catalogue / does this change touch it (push) Successful in 1m2s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 1m8s
wai-ledger service / does this change touch it (push) Successful in 1m2s
wai-fleet service / service · macos (push) Successful in 45s
wai-quantum for JavaScript / does this change touch it (push) Successful in 1m2s
wai-sink-android native sink library / does this change touch it (push) Successful in 48s
wai-sink-swift native sink package / does this change touch it (push) Successful in 46s
WAI Studio / does this change touch it (push) Successful in 46s
wai-quantum studio and desktop / does this change touch it (push) Successful in 49s
wai-audit collector and player integration / collector · linux (push) Successful in 1m20s
wai-ledger service / service · macos (push) Successful in 1m2s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m2s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m26s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 1m21s
wai-ledger service / service · linux (push) Failing after 1m50s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 3m15s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m9s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m54s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 3m44s
wai-fleet service / service · linux (push) Successful in 3m1s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 3m12s
wai-quantum studio and desktop / studio · browser (push) Successful in 4m9s
wai-quantum for JavaScript / javascript · linux (push) Successful in 4m29s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 4m28s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 5m26s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 6m10s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 5m45s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 8m3s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 7m22s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 7m23s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 9m59s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 10m52s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 13m34s
WAI Studio / Studio in WebKit · linux (push) Successful in 12m47s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 15m58s
cache-conformance tests what a cache answers from its store, and says how much it answered
Every MUST test could pass on a first GET, which a cache relays from the origin, so a cache that
broke §9 rule 3 only on what it stored passed the suite. C14 asks again, a second after the first
GET, for an object, a head proof, an alias, a live-edge part, a receipt file, a playlist receipt,
a key log and a parameter set, and holds each answer to the origin's bytes, ETag, Content-Type,
Cache-Control and Content-Length, with no Vary. Every 206 is held to the origin's headers too.

Three negative controls show the driver catches those faults: stored changes the headers of every
answer after a path's first, ranged those of every 206, and reflip a byte of every answer after a
path's first. S3 says how many of C14's repeats the
cache answered without reaching the origin and S4 how many of the run's requests reached it; the
summary marks a labelled cache whose store answered none as a relay.

A WAI_CACHE_DIR whose labels file is empty fails the run as an absent one does, and a labelled
cache that does not start has a row in the summary. wai http origin sends no Date while its
clock reads before the epoch (RFC 9110 §6.6.1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 12:07:00 -05:00

405 lines
19 KiB
Python
Executable file

#!/usr/bin/env python3
"""Cache conformance for HTTP delivery (extensions/http-delivery.md §9).
Runs tests.json against an HTTP cache that fronts an origin serving the objects of
wai/http-conformance/, and reports, per test, whether the cache met it. Standard library
only, and ../claim-conformance/_blake3.py to check each object's bytes against its
content hash; it shares no code with wai-rs.
python3 driver.py stage <http-conformance dir> <root>
lay out the files an origin serves for the tests under <root>
python3 driver.py run --base URL --root <root> [--origin URL] [--label L]
[--version V] [--config FILE] [--report report.json]
run the tests against the cache at URL (the origin itself, for a control)
`run` exits 1 when a MUST test fails, and prints `FAIL <id>: <detail>` for each. With
--origin, rule 3 is held to the origin's own headers, and the origin's hit counter
(`wai http origin --count-hits`) lets the informative tests see whether a request reached
the origin. A report names the cache by its label and version only.
"""
import hashlib, http.client, json, os, shutil, sys, time, urllib.parse
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
from _blake3 import blake3 # noqa: E402
DOMAIN = b"moq-jwp:object\x01"
# A cache may finish storing an object just after it has answered for it, so C14 waits this
# long before it asks again.
SETTLE_S = 1
def cid_of(b):
return blake3(DOMAIN + b).hex()
# ------------------------------------------------------------------- staging
def stage(corpus, root):
"""The origin's files: the corpus objects and head proofs, an alias, a live-edge
part, a receipt index, a receipt file, a playlist receipt, a key log and a
parameter set named by its digest."""
if os.path.exists(root):
shutil.rmtree(root)
os.makedirs(os.path.join(root, "objects"))
listing = json.load(open(os.path.join(corpus, "objects.json")))
for oid, o in listing.items():
for suffix in ("", ".hp"):
shutil.copyfile(os.path.join(corpus, o["path"] + suffix), os.path.join(root, o["path"] + suffix))
def put(rel, data):
p = os.path.join(root, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
open(p, "wb").write(data)
o8 = open(os.path.join(corpus, listing["O8"]["path"]), "rb").read()
o9b = open(os.path.join(corpus, listing["O9b"]["path"]), "rb").read()
put("alias/segment-8.wai", o8)
put("live/part-1.wai", o9b)
index = json.dumps({"kind": "wai-receipt-index", "object": listing["O3"]["cid"], "delivery": [], "claims": []}, sort_keys=True).encode()
put(listing["O3"]["path"] + ".receipts", index)
fixture = json.dumps({"kind": "cache-conformance-fixture", "of": "a receipt file"}, sort_keys=True).encode()
put("objects/rcpt/%s.json" % blake3(fixture).hex(), fixture)
playlist = json.dumps({"kind": "cache-conformance-fixture", "of": "a playlist receipt"}, sort_keys=True).encode()
put("objects/mr/%s.json" % blake3(playlist).hex(), playlist)
put(".well-known/wai-keys", json.dumps({"kind": "cache-conformance-fixture", "of": "a key log"}).encode())
pset = open(os.path.join(corpus, "..", "wai-rs", "tests", "vectors", "int_mlicpp", "model.wmb"), "rb").read()
put("sets/" + hashlib.sha256(pset).hexdigest(), pset)
manifest = {
"objects": {oid: {"path": o["path"], "cid": o["cid"], "n": o["n"]} for oid, o in listing.items()},
"alias": {"path": "alias/segment-8.wai", "of": "O8"},
"live": {"path": "live/part-1.wai", "of": "O9b"},
"index": listing["O3"]["path"] + ".receipts",
"receipt": "objects/rcpt/%s.json" % blake3(fixture).hex(),
"playlist_receipt": "objects/mr/%s.json" % blake3(playlist).hex(),
"key_log": ".well-known/wai-keys",
"set": "sets/" + hashlib.sha256(pset).hexdigest(),
}
open(os.path.join(root, "stage.json"), "w").write(json.dumps(manifest, indent=1) + "\n")
print("staged %d objects and their sidecars under %s" % (len(listing), root))
# ------------------------------------------------------------------- requests
class Response:
def __init__(self, status, headers, body):
self.status, self.body = status, body
self.headers = {}
for k, v in headers:
self.headers.setdefault(k.lower(), []).append(v)
def h(self, name):
v = self.headers.get(name.lower())
return None if v is None else ", ".join(v)
SENT = {} # the requests this run made, by the base they went to
def send(base, path, headers=None, method="GET"):
u = urllib.parse.urlsplit(base)
conn = http.client.HTTPConnection(u.hostname, u.port or 80, timeout=30)
prefix = u.path.rstrip("/")
h = {"Connection": "close"}
h.update(headers or {})
conn.request(method, prefix + "/" + path.lstrip("/"), headers=h)
r = conn.getresponse()
body = r.read()
out = Response(r.status, r.getheaders(), body)
conn.close()
return out
def request(base, path, headers=None, method="GET"):
SENT[base] = SENT.get(base, 0) + 1
return send(base, path, headers, method)
def hits(origin, path):
if origin is None:
return None
r = send(origin, "/__hits?path=" + urllib.parse.quote("/" + path.lstrip("/")))
return int(r.body) if r.status == 200 else None
# --------------------------------------------------------------------- tests
class Fail(Exception):
pass
def need(cond, why):
if not cond:
raise Fail(why)
def run_tests(base, root, origin):
st = json.load(open(os.path.join(root, "stage.json")))
obj = {k: open(os.path.join(root, v["path"]), "rb").read() for k, v in st["objects"].items()}
for k, v in st["objects"].items():
if cid_of(obj[k]) != v["cid"]:
raise SystemExit("the staged object %s does not hash to its cid" % k)
path = lambda k: st["objects"][k]["path"]
etag = lambda k: '"%s"' % st["objects"][k]["cid"]
results = {}
every_path = ([path(k) for k in obj] + [path(k) + ".hp" for k in obj] + [st["alias"]["path"], st["live"]["path"], st["index"],
st["receipt"], st["playlist_receipt"], st["key_log"], st["set"]])
at_start = {p: hits(origin, p) for p in every_path}
counted = None not in at_start.values()
references = {}
repeats = {}
def reference(p):
"""What rule 3 holds an answer for p to: the origin's own answer, and with no
--origin the first answer the cache gave."""
if p not in references:
references[p] = request(origin or base, p)
return references[p]
def headers_kept(r, p, what):
ref = reference(p)
for name in ("etag", "content-type", "cache-control", "vary", "access-control-allow-origin"):
need(r.h(name) == ref.h(name), "%s: %s %s, the origin's %s" % (what, name, r.h(name), ref.h(name)))
def whole_ok(r, k, what):
need(r.status == 200, "%s: status %d" % (what, r.status))
need(r.h("content-encoding") in (None, "identity"), "%s: Content-Encoding %s" % (what, r.h("content-encoding")))
need(r.body == obj[k], "%s: %d bytes that are not the object's %d" % (what, len(r.body), len(obj[k])))
def range_ok(r, k, a, b, what, allow_200=False, p=None):
n = len(obj[k])
if allow_200 and r.status == 200:
whole_ok(r, k, what)
return
need(r.status == 206, "%s: status %d" % (what, r.status))
need(r.h("content-encoding") in (None, "identity"), "%s: Content-Encoding %s" % (what, r.h("content-encoding")))
need(r.h("content-range") == "bytes %d-%d/%d" % (a, b, n), "%s: Content-Range %s" % (what, r.h("content-range")))
need(r.body == obj[k][a:b + 1], "%s: the bytes are not the object's [%d, %d]" % (what, a, b))
headers_kept(r, p or path(k), what)
def c1():
whole_ok(request(base, path("O3")), "O3", "cold")
whole_ok(request(base, path("O3")), "O3", "again")
def c2():
r = request(base, path("O2"))
whole_ok(r, "O2", "GET")
need(r.h("etag") == etag("O2"), "ETag %s" % r.h("etag"))
need((r.h("content-type") or "").startswith("application/wai"), "Content-Type %s" % r.h("content-type"))
need(r.h("cache-control") == "public, max-age=31536000, immutable, no-transform", "Cache-Control %s" % r.h("cache-control"))
need(r.h("content-length") == str(len(obj["O2"])), "Content-Length %s" % r.h("content-length"))
def c3():
request(base, path("O4"))
range_ok(request(base, path("O4"), {"Range": "bytes=1009-3000"}), "O4", 1009, 3000, "range")
def c4():
n = len(obj["O7"])
range_ok(request(base, path("O7"), {"Range": "bytes=1009-2999"}), "O7", 1009, 2999, "cold range", allow_200=True)
range_ok(request(base, path("O7"), {"Range": "bytes=0-1008"}), "O7", 0, 1008, "head range", allow_200=True)
need(n > 0, "empty")
def c5():
n = len(obj["O9c"])
request(base, path("O9c"))
range_ok(request(base, path("O9c"), {"Range": "bytes=-100"}), "O9c", n - 100, n - 1, "suffix")
range_ok(request(base, path("O9c"), {"Range": "bytes=%d-" % (n - 50)}), "O9c", n - 50, n - 1, "open")
def c6():
n = len(obj["O9c"])
r = request(base, path("O9c"), {"Range": "bytes=%d-" % n})
need(r.status == 416, "status %d" % r.status)
need(r.h("content-range") == "bytes */%d" % n, "Content-Range %s" % r.h("content-range"))
def c7():
for enc in ("gzip, deflate, br", "br", "zstd, gzip"):
whole_ok(request(base, path("O9a"), {"Accept-Encoding": enc}), "O9a", "Accept-Encoding %s" % enc)
def c8():
request(base, path("OD"))
range_ok(request(base, path("OD"), {"Range": "bytes=1009-3593", "If-Range": etag("OD")}), "OD", 1009, 3593, "matching If-Range")
whole_ok(request(base, path("OD"), {"Range": "bytes=1009-3593", "If-Range": "W/" + etag("OD")}), "OD", "weak If-Range")
whole_ok(request(base, path("OD"), {"Range": "bytes=1009-3593", "If-Range": '"%s"' % ("0" * 64)}), "OD", "other If-Range")
def c9():
a = request(base, path("O5"), {"User-Agent": "conformance-a", "Accept-Language": "en", "Accept": "*/*"})
b = request(base, path("O5"), {"User-Agent": "conformance-b", "Accept-Language": "fr", "Accept": "application/wai"})
whole_ok(a, "O5", "first")
whole_ok(b, "O5", "second")
need(a.h("vary") is None and b.h("vary") is None, "Vary %s / %s" % (a.h("vary"), b.h("vary")))
def c10():
p = path("O3") + ".hp"
r = request(base, p)
need(r.status == 200 and r.body == open(os.path.join(root, p), "rb").read(), "the head proof differs (status %d)" % r.status)
need(r.h("content-encoding") in (None, "identity"), "Content-Encoding %s" % r.h("content-encoding"))
need(r.h("cache-control") == "public, max-age=31536000, immutable, no-transform", "Cache-Control %s" % r.h("cache-control"))
def c11():
r = request(base, st["alias"]["path"])
whole_ok(r, st["alias"]["of"], "alias")
need(r.h("etag") == etag(st["alias"]["of"]), "ETag %s" % r.h("etag"))
def c12():
k, p = st["live"]["of"], st["live"]["path"]
r = request(base, p)
whole_ok(r, k, "live part")
cc = r.h("cache-control") or ""
need("max-age=31536000" not in cc and "max-age=" in cc, "Cache-Control %s" % cc)
range_ok(request(base, p, {"Range": "bytes=10-99"}), k, 10, 99, "live range", allow_200=True, p=p)
def c13():
r = request(base, path("O1"), {"Origin": "https://reader.example"})
whole_ok(r, "O1", "with Origin")
need(r.h("access-control-allow-origin") == "*", "Access-Control-Allow-Origin %s" % r.h("access-control-allow-origin"))
need("origin" not in (r.h("vary") or "").lower(), "Vary %s" % r.h("vary"))
def c14():
"""What a cache adds is its store: every test above can pass on a first GET alone, so
here each kind of resource is asked for again once the cache has had time to store
it. Every repeat is made before anything is judged, so S3 always has all of them."""
kept = [path("O2"), path("O3"), path("O5"), path("O3") + ".hp", st["alias"]["path"], st["live"]["path"],
st["receipt"], st["playlist_receipt"], st["key_log"], st["set"]]
for p in kept:
reference(p)
request(base, p)
request(base, path("O4"))
time.sleep(SETTLE_S)
for p in kept:
before = hits(origin, p)
r = request(base, p)
repeats[p] = (r, before == hits(origin, p))
for p in kept:
r, ref = repeats[p][0], reference(p)
need(r.status == 200 and r.body == ref.body, "%s, again: status %d, %d bytes, the origin's %d" % (p, r.status, len(r.body), len(ref.body)))
need(r.h("content-encoding") in (None, "identity"), "%s, again: Content-Encoding %s" % (p, r.h("content-encoding")))
need(r.h("content-length") == ref.h("content-length"), "%s, again: Content-Length %s" % (p, r.h("content-length")))
headers_kept(r, p, "%s, again" % p)
range_ok(request(base, path("O4"), {"Range": "bytes=1009-3000"}), "O4", 1009, 3000, "range, again")
def r1():
p = st["index"]
first = request(base, p)
need(first.status == 200, "status %d" % first.status)
changed = json.dumps({"kind": "wai-receipt-index", "changed_at": time.time()}).encode()
full = os.path.join(root, p)
old = open(full, "rb").read()
open(full, "wb").write(changed)
try:
second = request(base, p)
finally:
open(full, "wb").write(old)
need(second.status == 200 and second.body == changed, "the index came back unchanged")
def fetch_kept(p, cc=None):
r = request(base, p)
need(r.status == 200 and r.body == open(os.path.join(root, p), "rb").read(), "%s differs (status %d)" % (p, r.status))
need(r.h("content-encoding") in (None, "identity"), "Content-Encoding %s" % r.h("content-encoding"))
if cc is not None:
need(r.h("cache-control") == cc, "Cache-Control %s" % r.h("cache-control"))
return r
def kept(p, cc=None):
fetch_kept(p, cc)
imm = "public, max-age=31536000, immutable, no-transform"
def r5():
r = fetch_kept(st["set"], imm)
need(hashlib.sha256(r.body).hexdigest() == st["set"].rsplit("/", 1)[1], "the set does not hash to its name")
def s1():
if origin is None:
return "not run: no hit counter"
p = path("O8")
request(base, p)
before = hits(origin, p)
request(base, p)
return "stored" if hits(origin, p) == before else "not stored"
def s2():
if origin is None:
return "not run: no hit counter"
p = path("O9b")
request(base, p)
before = hits(origin, p)
request(base, p, {"Range": "bytes=100-199"})
return "answered from store" if hits(origin, p) == before else "forwarded"
def s3():
if not counted:
return "not run: no hit counter"
return "%d of %d repeat GETs answered from the store" % (sum(1 for _, stored in repeats.values() if stored), len(repeats))
def s4():
if not counted:
return "not run: no hit counter"
reached = sum(hits(origin, p) - n for p, n in at_start.items())
# The references went to the origin itself, past the cache.
return "%d of %d requests reached the origin" % (reached if base == origin else reached - SENT.get(origin, 0), SENT.get(base, 0))
table = {"C1": c1, "C2": c2, "C3": c3, "C4": c4, "C5": c5, "C6": c6, "C7": c7, "C8": c8, "C9": c9,
"C10": c10, "C11": c11, "C12": c12, "C13": c13, "C14": c14, "R1": r1,
"R2": lambda: kept(st["receipt"], imm), "R3": lambda: kept(st["playlist_receipt"], imm),
"R4": lambda: kept(st["key_log"], "max-age=300, no-transform"), "R5": r5, "S1": s1, "S2": s2, "S3": s3, "S4": s4}
tests = json.load(open(os.path.join(HERE, "tests.json")))["tests"]
out = []
for t in tests:
try:
detail = table[t["id"]]()
out.append({"id": t["id"], "name": t["name"], "level": t["level"], "pass": True, "detail": detail})
except Fail as e:
out.append({"id": t["id"], "name": t["name"], "level": t["level"], "pass": False, "detail": str(e)})
except (OSError, http.client.HTTPException) as e:
out.append({"id": t["id"], "name": t["name"], "level": t["level"], "pass": False, "detail": "transport: %s" % e})
return out
def requirements_md(report):
lines = ["# Cache configuration: %s" % report["cache_label"], "",
"Package version: %s. Configuration SHA-256: %s." % (report["package_version"], report["config_sha256"] or "none"), "",
"| test | level | result | detail |", "|---|---|---|---|"]
for r in report["results"]:
res = ("pass" if r["pass"] else "FAIL") if r["level"] == "MUST" else (r["detail"] or "")
lines.append("| %s %s | %s | %s | %s |" % (r["id"], r["name"], r["level"], res, "" if r["pass"] else r["detail"]))
musts = [r for r in report["results"] if r["level"] == "MUST"]
ok = all(r["pass"] for r in musts)
lines += ["", "This configuration %s http-delivery §9 for every MUST test." % ("meets" if ok else "does not meet")]
return "\n".join(lines) + "\n"
def main(argv):
if len(argv) == 3 and argv[0] == "stage":
stage(argv[1], argv[2])
return 0
if not argv or argv[0] != "run":
print(__doc__)
return 2
opt = lambda f, d=None: argv[argv.index(f) + 1] if f in argv else d
base, root = opt("--base"), opt("--root")
if base is None or root is None:
print(__doc__)
return 2
results = run_tests(base, root, opt("--origin"))
cfg = opt("--config")
report = {
"cache_label": opt("--label", "unlabelled"),
"package_version": opt("--version", "n/a"),
"config_sha256": hashlib.sha256(open(cfg, "rb").read()).hexdigest() if cfg else None,
"results": results,
}
if opt("--report"):
open(opt("--report"), "w").write(json.dumps(report, indent=1) + "\n")
open(os.path.join(os.path.dirname(opt("--report")) or ".", "requirements-%s.md" % report["cache_label"]), "w").write(requirements_md(report))
failed = [r for r in results if r["level"] == "MUST" and not r["pass"]]
for r in results:
if not r["pass"]:
print("FAIL %s: %s" % (r["id"], r["detail"]))
elif r["level"] != "MUST":
print("info %s: %s" % (r["id"], r["detail"]))
print("%s: %d/%d MUST tests pass" % (report["cache_label"], sum(1 for r in results if r["level"] == "MUST" and r["pass"]),
sum(1 for r in results if r["level"] == "MUST")))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))