#!/usr/bin/env python3 """Independent verifier of the audience decision corpus (extensions/audience-decision.md). Written from the extension's text. It shares no code with wai-rs, which generates the corpus, and never runs it. It takes from the second implementations of the texts this extension builds on what they define: documents, their canonical form, digest and tokens from route-record's (`route_record_verify.py`, route-record §2); region codes, their country and *holds* from rung price's (`rung_price_verify.py`, rung-price §2); the placement of a server key from cache authority's (`cache_authority_verify.py`, cache-authority §4.4); Ed25519 verification as SPEC §7 *Signatures* states it from the routine every Python verifier of WAI's corpora shares (`../claim-conformance/_ed25519.py`); and BLAKE3 from `../claim-conformance/_blake3.py`. Everything this extension adds is written here: - §4.3: a session's location aggregated against the listed codes of blackouts; - §4.4 to §4.6: a decision's members and rules, its evidence, what follows from its blackouts and region, its digest and its signature; - §4.7: a decision beside a session class, and the class's hash (http-delivery Appendix H); - §5.2 and §5.3: a territory statement's members and rules, the members that would name who serves, its digest and its signature; - §5.4 and §5.5: the statement that stands at an instant, and a region against it; - §5.6: the territory beside a placement. For every case it requires the outcome the corpus states, member for member; that every reason of §7's table appears in the corpus and that the corpus states no other; that a decision aggregated from a location applies exactly the blackouts that hold the location; that no placement changes when its statements are removed; and that no outcome depends on the order of a document's members, nor on the order of the statements given or on a statement given twice. It checks what no vector carries: the bound of a text. Then it mutates copies of the corpus, one defect each, and requires every copy to fail; and it drops each of its own checks in turn and requires the corpus to catch the weakened verifier. python3 -I audience_decision_verify.py Exit status 0 only if every case verifies, every mutated copy fails and every weakened verifier is caught. Python 3.8 or later; the `cryptography` package, which the cache authority verifier's readers import. """ import copy import hashlib import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) for d in ("claim-conformance", "jwp-conformance", "signer-conformance"): sys.path.insert(0, os.path.join(HERE, "..", d)) import _ed25519 as ED # noqa: E402 import _blake3 as B3 # noqa: E402 import route_record_verify as RR # noqa: E402 import rung_price_verify as RP # noqa: E402 import cache_authority_verify as CAV # noqa: E402 EXTENSION = os.path.join(HERE, "..", "extensions", "audience-decision.md") DECISION_KIND = "wai-audience-decision" # §4.1 TERRITORY_KIND = "wai-territory" # §5.1 DECISION_DOMAIN = b"wai:audience-decision\x01" # §4.4 TERRITORY_DOMAIN = b"wai:territory\x01" # §5.2 CLASS_DOMAIN = b"wai:video-manifest-class\x01" # http-delivery Appendix H MAX = (1 << 53) - 1 MAX_TEXT = 1 << 20 # §4.5, §5.3: 1,048,576 bytes MAX_BLACKOUTS = 256 # §4.1 MAX_REGIONS = 8192 # §4.2, §5.1 DECISION = ["kind", "version", "decider", "programme", "start", "end", "blackouts", "evidence", "applied", "verdict", "alternate", "sig"] # §4.1 BLACKOUT = ["id", "regions", "alternate"] # §4.2 EVIDENCE = ["region", "resolved"] # §4.3 CLASS = ["content_id", "entitlement", "geo", "blackouts", "ad_pod_id", "window"] # §4.7 step 2 TERRITORY = ["kind", "version", "publisher", "issued", "expires", "show", "regions", "sig"] # §5.1 SERVING = ["servers", "nodes", "origins", "roles", "may_sign"] # §5.3 VERDICTS = ["show", "withhold", "alternate"] # §4.1 SHOWS = ["everywhere", "only", "except"] # §5.1 MALFORMED = "audience_decision_malformed" NOT_AGGREGATED = "audience_location_not_aggregated" NOT_IN_SET = "audience_region_not_in_set" INCONSISTENT = "audience_decision_inconsistent" SIGNATURE = "audience_decision_signature_invalid" CLASS_MALFORMED = "audience_class_malformed" CLASS_MISMATCH = "audience_class_mismatch" T_MALFORMED = "territory_malformed" T_SERVING = "territory_scopes_serving" T_SIGNATURE = "territory_signature_invalid" T_NOT_STATED = "territory_not_stated" T_CONTRADICTED = "territory_contradicted" T_LAPSED = "territory_lapsed" # A weakened run drops one of these checks (see `WEAKENINGS`); None checks everything. DROPPED = None def drops(name): return DROPPED == name def section(heading): text = open(EXTENSION, encoding="utf-8").read() at = text.index("\n" + heading) end = text.find("\n## ", at + 1) return text[at:end if end >= 0 else len(text)] def reasons_table(): """§7's codes, in order.""" return [r.split("`")[1] for r in section("## 7. Reasons").splitlines() if r.startswith("| `")] class Refused(Exception): def __init__(self, reason): super().__init__(reason) self.reason = reason def need(ok, reason, check=None): """Refuse with `reason` unless `ok`, or unless `check` is the check a weakened run drops.""" if not ok and not (check is not None and drops(check)): raise Refused(reason) # ------------------------------------------------------------------ forms def is_token(v): return RR.is_token(v) def is_int(v): return RR.is_int(v) def region_code(v): return RP.region_code(v) def holds(codes, r): return RP.holds(codes, r) def ascending(xs): return all(xs[i].encode() < xs[i + 1].encode() for i in range(len(xs) - 1)) def regions_ok(v, lo, hi): """§4.2 and §5.1: `lo` to `hi` region codes, strictly ascending by bytes, with no subdivision code whose country is also listed.""" if not (isinstance(v, list) and lo <= len(v) <= hi and all(region_code(x) for x in v)): return False if not ascending(v) and not drops("regions_ascending"): return False if drops("one_encoding"): return True return not any(len(c) > 2 and RP.country(c) in v for c in v) def tokens_ok(v, hi): return isinstance(v, list) and len(v) <= hi and all(is_token(x) for x in v) and (ascending(v) or drops("applied_ascending")) def null_or_token(v): return v is None or is_token(v) def read_text(text): """A document of at most 1,048,576 bytes, or None.""" if not isinstance(text, str) or len(text.encode("utf-8")) > MAX_TEXT: return None return RR.read_document(text) _VERIFIED = {} def signed(key, domain, v, sig): """§4.4, §5.2: an Ed25519 signature by `key` over `domain` and the canonical form of `v` without `sig`, verified strictly.""" if drops("signature"): return True body = {k: x for k, x in v.items() if k != "sig"} if drops("signature_as_written"): form = json.dumps(body, separators=(",", ":"), ensure_ascii=False) else: form = RR.canonical(body) msg = (b"" if drops("signature_domain") else domain) + form.encode("utf-8") k = (key, hashlib.sha256(msg).digest(), sig) if k not in _VERIFIED: try: _VERIFIED[k] = ED.verify(bytes.fromhex(key), msg, bytes.fromhex(sig)) is None except ValueError: _VERIFIED[k] = False return _VERIFIED[k] def digest(v): return RR.digest({k: x for k, x in v.items() if k != "sig"}) # ------------------------------------------------------------------ §4.3 and §4.6 def listed(blackouts): """§2 *Listed codes*: every code any blackout lists, distinct, in byte order.""" return sorted({c for b in blackouts for c in b["regions"]}, key=lambda c: c.encode()) def aggregate(blackouts, location): """§4.3: the evidence a session's location aggregates to.""" codes = listed(blackouts) if location is None: return {"region": None, "resolved": False} if location in codes: return {"region": location, "resolved": True} if len(location) > 2 and RP.country(location) in codes and not drops("aggregate_country"): return {"region": RP.country(location), "resolved": True} if len(location) == 2 and any(len(c) > 2 and RP.country(c) == location for c in codes) and not drops("aggregate_unresolved"): return {"region": None, "resolved": False} return {"region": None, "resolved": True} def follows(blackouts, region, resolved): """§4.6: (applied, verdict, alternate).""" if not resolved and not drops("unresolved_applies_all"): apply = list(blackouts) elif region is None: apply = [] else: apply = [b for b in blackouts if holds(b["regions"], region)] ids = [b["id"] for b in apply] if not apply: return ids, "show", None alts = {b["alternate"] for b in apply} if len(alts) == 1 and None not in alts: return ids, "alternate", apply[0]["alternate"] if drops("alternates_agree") and apply[0]["alternate"] is not None: return ids, "alternate", apply[0]["alternate"] return ids, "withhold", None # ------------------------------------------------------------------ §4.5 def blackout_ok(b): return (RR.exactly(b, BLACKOUT) and is_token(b["id"]) and regions_ok(b["regions"], 1, MAX_REGIONS) and null_or_token(b["alternate"])) def decision_form(v): """Rules 1 to 4.""" if not RR.exactly(v, DECISION): return False if v["kind"] != DECISION_KIND or not RR.same(v["version"], 1): return False if not (RR.is_hex(v["decider"], 64) and is_token(v["programme"]) and is_int(v["start"]) and is_int(v["end"]) and RR.is_hex(v["sig"], 128)): return False if v["start"] >= v["end"] and not drops("start_below_end"): return False bs = v["blackouts"] if not (isinstance(bs, list) and len(bs) <= MAX_BLACKOUTS and all(blackout_ok(b) for b in bs)): return False if not ascending([b["id"] for b in bs]) and not drops("blackouts_ascending"): return False e = v["evidence"] if not (isinstance(e, dict) and "region" in e and "resolved" in e): return False if not (e["region"] is None or region_code(e["region"])) or not isinstance(e["resolved"], bool): return False if e["resolved"] is False and e["region"] is not None and not drops("unresolved_no_region"): return False if not tokens_ok(v["applied"], MAX_BLACKOUTS): return False if v["verdict"] not in VERDICTS or not null_or_token(v["alternate"]): return False if (v["alternate"] is not None) != (v["verdict"] == "alternate") and not drops("alternate_with_verdict"): return False return True def read_decision(text): """§4.5: the decision's value, or Refused.""" v = read_text(text) need(v is not None and decision_form(v), MALFORMED) e, codes = v["evidence"], listed(v["blackouts"]) # Rule 5. need(set(e) == set(EVIDENCE), NOT_AGGREGATED, "evidence_members") r = e["region"] if r is not None: need(r in codes or not holds(codes, r), NOT_AGGREGATED, "finer_region") # Rule 6. need(r in codes, NOT_IN_SET, "in_set") # Rule 7. applied, verdict, alternate = follows(v["blackouts"], r, e["resolved"]) need(RR.same([applied, verdict, alternate], [v["applied"], v["verdict"], v["alternate"]]), INCONSISTENT, "follows") need(signed(v["decider"], DECISION_DOMAIN, v, v["sig"]), SIGNATURE) return v def decision_outcome(text): try: v = read_decision(text) except Refused as r: return {"outcome": "refuse", "reason": r.reason} return {"outcome": "read", "digest": digest(v), "verdict": v["verdict"], "applied": v["applied"], "alternate": v["alternate"]} # ------------------------------------------------------------------ §4.7 def lp(s): b = s.encode("utf-8") return len(b).to_bytes(4, "big") + b def class_hash(c): """http-delivery Appendix H's session_class_hash.""" bs = sorted(set(c["blackouts"]), key=lambda s: s.encode()) data = CLASS_DOMAIN + lp(c["content_id"]) + lp(c["entitlement"]) + lp(c["geo"]) + lp(c["ad_pod_id"]) data += len(bs).to_bytes(4, "big") + b"".join(lp(s) for s in bs) + c["window"].to_bytes(8, "big") return B3.blake3(data).hex() def class_outcome(case): try: v = read_decision(case["decision"]) except Refused as r: return {"outcome": "refuse", "reason": r.reason} c = case["class"] ok = (RR.exactly(c, CLASS) and all(isinstance(c[m], str) for m in ("content_id", "entitlement", "geo", "ad_pod_id")) and isinstance(c["blackouts"], list) and all(isinstance(b, str) for b in c["blackouts"]) and is_int(c["window"])) if not ok and not drops("class_form"): return {"outcome": "refuse", "reason": CLASS_MALFORMED} mismatch = lambda m: {"outcome": "refuse", "reason": CLASS_MISMATCH, "member": m} # noqa: E731 if c["content_id"] != v["programme"] and not drops("class_content"): return mismatch("content_id") distinct = list(c["blackouts"]) if drops("class_distinct") else sorted(set(c["blackouts"]), key=lambda s: s.encode()) if distinct != v["applied"] and not drops("class_blackouts"): return mismatch("blackouts") r, resolved = v["evidence"]["region"], v["evidence"]["resolved"] if r is not None: geo_ok = c["geo"] == RP.country(r) elif resolved: geo_ok = c["geo"] not in listed(v["blackouts"]) or drops("class_geo_outside") else: geo_ok = True if not geo_ok and not drops("class_geo"): return mismatch("geo") return {"outcome": "consistent", "session_class_hash": class_hash(c)} # ------------------------------------------------------------------ §5.3 def territory_form(v): """Rules 1 to 3.""" if not RR.exactly(v, TERRITORY): return False if v["kind"] != TERRITORY_KIND or not RR.same(v["version"], 1): return False if not (RR.is_hex(v["publisher"], 64) and is_int(v["issued"]) and is_int(v["expires"]) and RR.is_hex(v["sig"], 128)): return False if v["issued"] >= v["expires"] and not drops("issued_below_expires"): return False if v["show"] not in SHOWS or not regions_ok(v["regions"], 0, MAX_REGIONS): return False if (len(v["regions"]) == 0) != (v["show"] == "everywhere") and not drops("everywhere_empty"): return False return True def read_territory(text): """§5.3: the statement's value, or Refused.""" v = read_text(text) need(v is not None, T_MALFORMED) need(not (isinstance(v, dict) and any(m in v for m in SERVING)), T_SERVING, "serving") need(territory_form(v), T_MALFORMED) need(signed(v["publisher"], TERRITORY_DOMAIN, v, v["sig"]), T_SIGNATURE) return v def statement_outcome(text): try: v = read_territory(text) except Refused as r: return {"outcome": "refuse", "reason": r.reason} return {"outcome": "read", "digest": digest(v), "publisher": v["publisher"], "show": v["show"]} # ------------------------------------------------------------------ §5.4 and §5.5 def standing(texts, publisher, at_us): """§5.4: (the standing statement, None) or (None, the reason none stands).""" mine = [] for t in texts: try: v = read_territory(t) except Refused: continue if (v["publisher"] == publisher or drops("publisher")) and (v["issued"] <= at_us or drops("issued_by_then")): mine.append(v) if not mine: return None, T_NOT_STATED latest = max(v["issued"] for v in mine) at = [v for v in mine if v["issued"] == latest] if len({digest(v) for v in at}) > 1 and not drops("contradicted"): return None, T_CONTRADICTED v = at[0] if v["expires"] <= at_us and not drops("lapsed"): return None, T_LAPSED return v, None def territory_word(v, region): """§5.5.""" if v["show"] == "everywhere": return "within" if region is None: return "undetermined" rs = v["regions"] country_of_listed = len(region) == 2 and any(len(c) > 2 and RP.country(c) == region for c in rs) if holds(rs, region): return "outside" if v["show"] == "except" else "within" if country_of_listed and not drops("undetermined"): return "undetermined" return "within" if v["show"] == "except" or drops("except") else "outside" def territory_label(texts, publisher, region, at_us): v, reason = standing(texts, publisher, at_us) if v is None: return f"unstated reason={reason}" return f"{territory_word(v, region)} statement={digest(v)}" def territory_outcome(case): return {"territory": territory_label(case["texts"], case["publisher"], case["region"], case["at_us"])} # ------------------------------------------------------------------ §5.6 def beside(case, texts): placement = CAV.place(case["key"], case["at_ms"], case["heads"]) label = territory_label(texts, case["publisher"], case["region"], case["at_ms"] * 1000) if drops("placement_alone") and label.startswith("outside ") and placement.startswith("confirmed "): placement = "refused reason=outside_territory" return placement, label def placement_outcome(case): placement, label = beside(case, case["texts"]) return {"placement": placement, "territory": label} def aggregation_outcome(case): return aggregate(case["blackouts"], case["location"]) RUNS = (("aggregations", aggregation_outcome), ("decisions", lambda c: decision_outcome(c["text"])), ("classes", class_outcome), ("statements", lambda c: statement_outcome(c["text"])), ("territories", territory_outcome), ("placements", placement_outcome)) KINDS = tuple(k for k, _ in RUNS) # ------------------------------------------------------------------ the corpus def reordered(text): """`text`'s document with its members in reverse order, or None when it is not a document object.""" v = read_text(text) if not isinstance(v, dict): return None return json.dumps(dict(reversed(list(v.items()))), indent=1, ensure_ascii=False) def invariances(doc, fail): # A decision aggregated from a location applies exactly the blackouts that hold the location. for c in doc["aggregations"]: e = aggregate(c["blackouts"], c["location"]) if e["resolved"] and c["location"] is not None: direct = [b["id"] for b in c["blackouts"] if holds(b["regions"], c["location"])] if follows(c["blackouts"], e["region"], True)[0] != direct: fail(f"aggregations/{c['name']}: the aggregation applies other blackouts than the location does") # The order of a document's members. for kind, run in (("decisions", decision_outcome), ("statements", statement_outcome)): for c in doc[kind]: t = reordered(c["text"]) if t is not None and len(t.encode()) <= MAX_TEXT and not RR.same(run(t), c["outcome"]): fail(f"{kind}/{c['name']}: its members in another order give another outcome") # The order of the statements given, and a statement given twice. for c in doc["territories"]: for texts in (list(reversed(c["texts"])), c["texts"] + c["texts"]): if territory_label(texts, c["publisher"], c["region"], c["at_us"]) != c["outcome"]["territory"]: fail(f"territories/{c['name']}: the statements in another order, or twice, give another territory") # §5.6: the placement is the same with no statement at all. for c in doc["placements"]: if beside(c, [])[0] != beside(c, c["texts"])[0]: fail(f"placements/{c['name']}: its placement changes with its statements") def strings(v, name, out): if isinstance(v, dict): for k, x in v.items(): if k == name and isinstance(x, str): out.append(x) strings(x, name, out) elif isinstance(v, list): for x in v: strings(x, name, out) def check(doc, quiet=False, full=True): fails = [] def fail(msg): fails.append(msg) if not quiet: print("FAIL", msg) for kind, run in RUNS: cases = doc.get(kind) or [] if not cases: fail(f"no {kind} cases") for c in cases: try: got = run(c) except (KeyError, TypeError, ValueError, AttributeError) as e: fail(f"{kind}/{c.get('name')}: {e!r}") continue if not RR.same(got, c["outcome"]): fail(f"{kind}/{c['name']}: stated {json.dumps(c['outcome'])} got {json.dumps(got)}") table = reasons_table() outcomes = [c["outcome"] for k in KINDS for c in doc.get(k) or []] reasons, labels = [], [] strings(outcomes, "reason", reasons) strings(outcomes, "territory", labels) stated = set(reasons) | {s.split("reason=", 1)[1] for s in labels if "reason=" in s} for r in table: if r not in stated: fail(f"no case gives {r}") for r in sorted(stated - set(table)): fail(f"a case gives {r}, which §7 does not list") for word in ("within", "outside", "undetermined", "unstated"): if not any(s.startswith(word + " ") for s in labels): fail(f"no territory is {word}") if full: try: invariances(doc, fail) except (KeyError, TypeError, ValueError, AttributeError) as e: fail(f"invariances: {e!r}") return fails WEAKENINGS = ( "regions_ascending", "one_encoding", "applied_ascending", "start_below_end", "blackouts_ascending", "unresolved_no_region", "alternate_with_verdict", "evidence_members", "finer_region", "in_set", "follows", "unresolved_applies_all", "alternates_agree", "aggregate_country", "aggregate_unresolved", "signature", "signature_domain", "signature_as_written", "class_form", "class_content", "class_distinct", "class_blackouts", "class_geo", "class_geo_outside", "issued_below_expires", "everywhere_empty", "serving", "publisher", "issued_by_then", "contradicted", "lapsed", "undetermined", "except", "placement_alone", ) def mutations(doc): """Copies of the corpus, one defect each, every one of which must fail.""" def case(d, kind, name): return next(c for c in d[kind] if c["name"] == name) def m_aggregate_location(d): case(d, "aggregations", "subdivision_of_a_listed_country")["location"] = "XB-1" def m_aggregate_outcome(d): case(d, "aggregations", "country_of_listed_subdivisions")["outcome"]["resolved"] = True def m_verdict(d): case(d, "decisions", "withhold_unresolved")["outcome"]["verdict"] = "alternate" def m_applied(d): case(d, "decisions", "alternate_in_a_listed_subdivision")["outcome"]["applied"] = ["partner"] def m_digest(d): case(d, "decisions", "show_outside")["outcome"]["digest"] = "00" * 32 def m_sig(d): c = case(d, "decisions", "show_outside") v = json.loads(c["text"]) v["sig"] = ("01" if v["sig"][:2] != "01" else "02") + v["sig"][2:] c["text"] = RR.canonical(v) def m_aggregated_reason(d): case(d, "decisions", "evidence_coordinates")["outcome"]["reason"] = NOT_IN_SET def m_in_set_reason(d): case(d, "decisions", "region_the_blackouts_do_not_hold")["outcome"]["reason"] = INCONSISTENT def m_class_geo(d): case(d, "classes", "consistent_alternate")["class"]["geo"] = "XA" def m_class_hash(d): case(d, "classes", "consistent_outside")["outcome"]["session_class_hash"] = "00" * 32 def m_class_member(d): case(d, "classes", "geo_not_the_regions_country")["outcome"]["member"] = "blackouts" def m_statement_reason(d): case(d, "statements", "names_servers")["outcome"]["reason"] = T_MALFORMED def m_statement_show(d): case(d, "statements", "except")["outcome"]["show"] = "only" def m_territory_at(d): case(d, "territories", "at_issued")["at_us"] -= 1 def m_territory_region(d): case(d, "territories", "only_sibling_subdivision")["region"] = "XB-1" def m_territory_word(d): c = case(d, "territories", "except_country_of_a_listed_subdivision") c["outcome"]["territory"] = c["outcome"]["territory"].replace("undetermined", "within", 1) def m_placement_key(d): case(d, "placements", "confirmed_outside")["key"] = case(d, "placements", "revoked_within")["key"] def m_placement_outcome(d): case(d, "placements", "confirmed_outside")["outcome"]["placement"] = "refused reason=outside_territory" def m_reason_gone(d): d["territories"] = [c for c in d["territories"] if "territory_contradicted" not in c["outcome"]["territory"]] def m_reason_unknown(d): case(d, "statements", "names_roles")["outcome"]["reason"] = "territory_roles" def m_no_kind(d): d["classes"] = [] return [m_aggregate_location, m_aggregate_outcome, m_verdict, m_applied, m_digest, m_sig, m_aggregated_reason, m_in_set_reason, m_class_geo, m_class_hash, m_class_member, m_statement_reason, m_statement_show, m_territory_at, m_territory_region, m_territory_word, m_placement_key, m_placement_outcome, m_reason_gone, m_reason_unknown, m_no_kind] def direct(doc): """§4.5's and §5.3's bound of 1,048,576 bytes: a text of that many bytes, whitespace added, reads as itself, and one of a byte more is refused.""" fails = [] for kind, name, run, refusal in (("decisions", "alternate_in_a_listed_subdivision", decision_outcome, MALFORMED), ("statements", "only", statement_outcome, T_MALFORMED)): c = next(x for x in doc[kind] if x["name"] == name) pad = MAX_TEXT - len(c["text"].encode("utf-8")) if not RR.same(run(c["text"] + " " * pad), c["outcome"]): fails.append(f"{kind}/{name} of 1,048,576 bytes, whitespace added, did not read as itself") if run(c["text"] + " " * (pad + 1)) != {"outcome": "refuse", "reason": refusal}: fails.append(f"{kind}/{name} of 1,048,577 bytes was not refused {refusal}") return fails def main(argv): global DROPPED if len(argv) != 2: print(__doc__) return 2 doc = json.load(open(os.path.join(argv[1], "vectors.json"), encoding="utf-8")) extra = sorted(set(os.listdir(argv[1])) - {"vectors.json", "README.md"}) fails = check(doc) if extra: fails.append(f"files the corpus does not hold: {extra}") print(f"{sum(len(doc[k]) for k in KINDS)} cases ({', '.join(f'{len(doc[k])} {k}' for k in KINDS)}): {len(fails)} failure(s)") b = direct(doc) print(f"what no vector carries: {len(b)} failure(s)") fails += b caught = 0 ms = mutations(doc) for m in ms: d = copy.deepcopy(doc) m(d) if check(d, quiet=True): caught += 1 else: fails.append(f"mutation {m.__name__} was not caught") print(f"{caught} of {len(ms)} mutated copies fail") weak = 0 for w in WEAKENINGS: DROPPED = w try: if check(doc, quiet=True, full=False): weak += 1 else: fails.append(f"a verifier without its {w} check passes the corpus") finally: DROPPED = None print(f"{weak} of {len(WEAKENINGS)} weakened verifiers are caught") for f in fails: print("FAIL", f) return 1 if fails else 0 if __name__ == "__main__": sys.exit(main(sys.argv))