#!/usr/bin/env python3 """carousel_moq_fec_verify.py: a second implementation of the MoQ FEC catalog binding (extensions/carousel.md §8.3) and of the multicast endpoint reading (bearer-receipts §9.7). carousel_moq_fec_verify.py [CORPUS_DIR] Written from those sections and from draft-ramadan-moq-fec-catalog-00, -fec-core-00 and -fec-block-codes-00; it shares no code with wai-rs. For live-conformance/carousel-moq-fec/ it binds every group's entries itself and requires the instances, the catalog tracks, each object's SS_ID and block, its Protected Object Unit, its symbol-track objects and the header of every repair object; reads every catalog back and compares it with the schedule; rebuilds every object from its symbol track and holds it to its entry's content hash; gives every entry that does not bind, every catalog and every endpoint its outcome; plants a symbol of another object in the ways that need no decoding; and changes cases and requires each change to be caught. It does not implement RFC 6330: the values of the repair symbols are checked by wai-rs's tests/carousel_moq_fec.rs alone, against an independent implementation of that RFC. K' is read from carousel.md Appendix A, and the content hash is claim-conformance's BLAKE3 (_blake3.py, written from its specification). Exit 0 when everything holds, 1 with one line a finding otherwise. Standard library only. """ import copy import ipaddress import json import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) from _blake3 import blake3 # noqa: E402 (written from its specification; no package) K_MAX, MAX_ESI, MAX_LAYERS = 56403, (1 << 24) - 1, 256 DOMAIN_OBJECT = b"moq-jwp:object\x01" GEOMETRY = ("scheme", "blockProfile", "mmtpMode", "timescale", "symbolSize", "interleaveDepthMs", "reorderToleranceMs", "sourceSymbols", "fec") class Refuse(Exception): pass def k_primes(): with open(os.path.join(HERE, "..", "extensions", "carousel.md"), encoding="utf-8") as fh: text = fh.read() a, b = text.index(""), text.index("") return [int(x) for x in re.findall(r"[0-9]+", text[a + len(""):b])] K_PRIME = k_primes() def k_prime(k): return next((v for v in K_PRIME if v >= k), None) def ceil_div(a, b): return -(-a // b) def varint_bytes(v): for n in range(1, 9): if v < 1 << (7 * n): b = v.to_bytes(n, "big") return bytes([b[0] | (0xFF << (9 - n)) & 0xFF if n > 1 else b[0]]) + b[1:] return b"\xff" + v.to_bytes(8, "big") def read_varint(b, at): ones = 0 while ones < 8 and b[at] & (0x80 >> ones): ones += 1 n = 9 if ones == 8 else ones + 1 if at + n > len(b): raise IndexError if n == 9: return int.from_bytes(b[at + 1:at + 9], "big"), at + 9 return int.from_bytes(bytes([b[at] & (0xFF >> n)]) + b[at + 1:at + n], "big"), at + n def immutable_properties(instance, ss_id): """fec-core §5.6: the FEC Source Info Property alone, type 0x11.""" value = (bytes([instance]) if instance else b"") + ss_id.to_bytes(4, "big") return varint_bytes(0x11) + varint_bytes(len(value)) + value def source_info(immutable, instance): """The SS_ID the properties give the object in `instance`, or None.""" at, ty, found = 0, 0, None try: while at < len(immutable): d, at = read_varint(immutable, at) ty += d if ty % 2 == 0: _, at = read_varint(immutable, at) continue n, at = read_varint(immutable, at) if n > 0xFFFF or at + n > len(immutable): return None v, at = immutable[at:at + n], at + n if ty == 0x0B or (ty == 0x11 and found is not None): return None if ty == 0x11: found = v except IndexError: return None if found is None: return None if len(found) == 4: return int.from_bytes(found, "big") if instance == 0 else None entries = [found[i:i + 5] for i in range(0, len(found), 5)] if not found or len(found) % 5 or (len(entries) == 1 and entries[0][0] == 0) or any(a[0] >= b[0] for a, b in zip(entries, entries[1:])): return None for e in entries: if e[0] == instance: return int.from_bytes(e[1:], "big") return None def unit(group, obj, immutable, payload): """fec-core §5.2.""" return group.to_bytes(8, "big") + obj.to_bytes(8, "big") + len(immutable).to_bytes(2, "big") + immutable + len(payload).to_bytes(4, "big") + payload def symbols(u, t): return [u[i:i + t].ljust(t, b"\0") for i in range(0, len(u), t)] def geometry(e, instance): """§8.3: (T, s, layers) for an entry whose object goes to `instance`.""" o = e["oti"] if e["fec"] != "raptorq" or o["source_blocks"] != 1 or o["sub_blocks"] != 1: raise Refuse("binding_no_form") f, t = o["transfer_length"], o["symbol_size"] if t % 8 or o["alignment"] != 8: raise Refuse("binding_symbol_size") k = ceil_div(f, t) s = ceil_div(22 + (6 if instance == 0 else 7) + f, t) if s > K_MAX or k_prime(s) != k_prime(k): raise Refuse("binding_block_differs") m = k + e["repair"] if m - s < 1: raise Refuse("binding_no_repair") if e["esi"] == "repeat": layers = [m - s] else: if e["count"] > MAX_LAYERS: raise Refuse("binding_no_form") layers = [m - s] + [m] * (e["count"] - 1) return t, s, layers def bind(entries, first_ss_id): """§8.3 Instances and layers: (instances, objects).""" instances, nxt, objects = [], [], [] for e in entries: g, at = None, None try: zero = geometry(e, 0) if not instances or (instances[0]["symbol_size"], instances[0]["layers"]) == (zero[0], zero[2]): g, at = zero, 0 except Refuse: if not instances: raise if g is None: g = geometry(e, 1) at = next((i for i in range(1, len(instances)) if (instances[i]["symbol_size"], instances[i]["layers"]) == (g[0], g[2])), len(instances)) t, s, layers = g if at == len(instances): if at > 255: raise Refuse("binding_too_many_instances") instances.append({"id": at, "symbol_size": t, "source_symbols": 0, "layers": layers, "span_ms": 0}) nxt.append(first_ss_id) i = instances[at] i["source_symbols"], i["span_ms"] = max(i["source_symbols"], s), max(i["span_ms"], e["duration_ms"]) if i["source_symbols"] + sum(i["layers"]) - 1 > MAX_ESI: raise Refuse("binding_no_form") objects.append({"leaf_index": e["leaf_index"], "instance": at, "ss_id": nxt[at] % (1 << 32), "block_group": nxt[at], "symbols": s}) nxt[at] += s if not objects: raise Refuse("binding_no_form") return instances, objects def catalog_tracks(instances, track): def rname(i, layer): mid = "" if i == 0 else "/i%d" % i return track + mid + "/repair" + ("" if layer == 0 else "/%d" % layer) def sname(i): return track + ("" if i == 0 else "/i%d" % i) + "/symbols" fec, tracks = [], [] for i in instances: fec.append({"instanceId": i["id"], "scheme": "raptorq", "blockProfile": "moq-object-v1", "sourceSymbols": i["source_symbols"], "symbolSize": i["symbol_size"], "repairSymbols": i["layers"][0], "interleaveDepthMs": i["span_ms"], "repairTrack": rname(i["id"], 0), "symbolTrack": sname(i["id"])}) tracks.append({"name": sname(i["id"]), "packaging": "fec-source", "isLive": True, "source": track, "instanceId": i["id"], "priority": 128}) for layer, p in enumerate(i["layers"]): tracks.append({"name": rname(i["id"], layer), "packaging": "fec-repair", "isLive": True, "source": track, "instanceId": i["id"], "repairLayer": layer, "repairSymbols": p, "priority": min(240 + layer, 255)}) return fec, tracks def integer(m, k, lo, hi): if k not in m: return None v = m[k] if isinstance(v, bool) or not isinstance(v, int) or not lo <= v <= hi: raise Refuse("binding_catalog_malformed") return v def text(m, k): if k not in m: return None if not isinstance(m[k], str): raise Refuse("binding_catalog_malformed") return m[k] def read_catalog(tracks, track): """§8.3 And back: the instances a catalog declares for `track`, failing closed.""" bad = Refuse("binding_catalog_malformed") if any(not isinstance(t, dict) or "enhancementRepair" in t or any(isinstance(i, dict) and "enhancementRepair" in i for i in (t.get("fec") if isinstance(t.get("fec"), list) else [])) for t in tracks): raise bad def entry(name): got = [t for t in tracks if t.get("name") == name] if len(got) != 1: raise bad return got[0] source = entry(track) fec = source.get("fec") if not isinstance(fec, list) or not fec: raise bad out, named, served = [], [], [] for f in fec: if not isinstance(f, dict): raise bad iid = integer(f, "instanceId", 0, 255) or 0 if any(i["id"] == iid for i in out): raise bad if (text(f, "scheme"), text(f, "blockProfile")) != ("raptorq", "moq-object-v1"): raise Refuse("binding_no_form") k, t, p0 = integer(f, "sourceSymbols", 1, K_MAX), integer(f, "symbolSize", 8, 65535), integer(f, "repairSymbols", 1, MAX_ESI) if k is None or t is None or t % 8 or p0 is None: raise bad span = integer(f, "interleaveDepthMs", 1, (1 << 64) - 1) or 0 if "reorderToleranceMs" in f and (isinstance(f["reorderToleranceMs"], bool) or not isinstance(f["reorderToleranceMs"], (int, float)) or f["reorderToleranceMs"] < 0 or f["reorderToleranceMs"] != f["reorderToleranceMs"]): raise bad if "label" in f and not isinstance(f["label"], str): raise bad layer0, sym = text(f, "repairTrack"), text(f, "symbolTrack") if layer0 is None: raise bad for n in (layer0, sym): if n is not None: if n in named: raise bad named.append(n) layers = {} for tr in tracks: name = text(tr, "name") if name is None: raise bad is0 = name == layer0 if text(tr, "packaging") != "fec-repair": if is0: raise bad continue src = text(tr, "source") if src is None: serves = is0 else: tid = integer(tr, "instanceId", 0, 255) serves = src == track and (tid if tid is not None else (iid if is0 else 0)) == iid if not serves: if is0: raise bad continue if any(g in tr for g in GEOMETRY): raise bad layer = integer(tr, "repairLayer", 0, MAX_ESI) if layer is None: if not is0: raise bad layer = 0 if (layer == 0) != is0: raise bad p = integer(tr, "repairSymbols", 1, MAX_ESI) if p is None: if layer != 0: raise bad p = p0 elif layer == 0 and p != p0: raise bad prio = integer(tr, "priority", 192, 255) if layer in layers: raise bad layers[layer] = (name, p, 240 if prio is None else prio) order = sorted(layers) if not order or order != list(range(len(order))) or any(layers[a][2] > layers[b][2] for a, b in zip(order, order[1:])): raise bad if sym is not None: st = entry(sym) if text(st, "packaging") != "fec-source" or text(st, "source") not in (None, track) or integer(st, "instanceId", 0, 255) not in (None, iid) \ or "repairLayer" in st or "repairSymbols" in st or any(g in st for g in GEOMETRY): raise bad integer(st, "priority", 0, 255) served.append(sym) inst = {"id": iid, "symbol_size": t, "source_symbols": k, "layers": [layers[x][1] for x in order], "span_ms": span} if k + sum(inst["layers"]) - 1 > MAX_ESI: raise bad served += [layers[x][0] for x in order] out.append(inst) for tr in tracks: if text(tr, "packaging") in ("fec-repair", "fec-source") and text(tr, "source") in (None, track) and text(tr, "name") not in served: raise bad return sorted(out, key=lambda i: i["id"]) def same_instances(bound, read): return len(bound) == len(read) and all( a["id"] == b["id"] and a["symbol_size"] == b["symbol_size"] and a["layers"] == b["layers"] and a["source_symbols"] <= b["source_symbols"] and (b["span_ms"] == 0 or a["span_ms"] <= b["span_ms"]) for a, b in zip(bound, read)) def rebuild(block, inst, ss_start): """fec-core §9.2: the objects of a block's source symbols.""" bad = Refuse("binding_unit_malformed") t = inst["symbol_size"] if not block or len(block) % t: raise bad out, at = [], 0 while at < len(block): if at + 18 > len(block): raise bad group, obj, il = int.from_bytes(block[at:at + 8], "big"), int.from_bytes(block[at + 8:at + 16], "big"), int.from_bytes(block[at + 16:at + 18], "big") if at + 22 + il > len(block): raise bad immutable = block[at + 18:at + 18 + il] pl = int.from_bytes(block[at + 18 + il:at + 22 + il], "big") if at + 22 + il + pl > len(block): raise bad payload = block[at + 22 + il:at + 22 + il + pl] nxt = at + ceil_div(22 + il + pl, t) * t if nxt > len(block) or any(block[at + 22 + il + pl:nxt]): raise bad if source_info(immutable, inst["id"]) != (ss_start + at // t) % (1 << 32) or any((o[0], o[1]) == (group, obj) for o in out): raise bad out.append((group, obj, immutable, payload)) at = nxt return out def check_object(o, group, e): if (o[0], o[1]) != (group, e["leaf_index"]): raise Refuse("binding_unit_malformed") if len(o[3]) != e["oti"]["transfer_length"] or blake3(DOMAIN_OBJECT + o[3]).hex() != e["content_hash"]: raise Refuse("binding_content_mismatch") def read_repair(b, t): if len(b) != 13 + t: return None return {"ss_start": int.from_bytes(b[:4], "big"), "rsb": int.from_bytes(b[4:7], "big"), "rs_id": int.from_bytes(b[7:10], "big"), "ssb": int.from_bytes(b[10:13], "big"), "symbol": b[13:]} def accept_repair(inst, block, layer, group, obj, payload): """§8.3 Symbols: whether a repair object fits the block (its Group and, once known, SSB_length).""" r = read_repair(payload, inst["symbol_size"]) return not (r is None or group != block["group"] or group % (1 << 32) != r["ss_start"] or r["ssb"] == 0 or r["ssb"] > inst["source_symbols"] or (block["ssb"] is not None and block["ssb"] != r["ssb"]) or r["rsb"] != sum(inst["layers"]) or layer >= len(inst["layers"]) or obj >= inst["layers"][layer] or r["rs_id"] != sum(inst["layers"][:layer]) + obj) def accept_symbol(inst, block, group, obj, payload): limit = block["ssb"] if block["ssb"] is not None else inst["source_symbols"] return group == block["group"] and len(payload) == inst["symbol_size"] and obj < limit def in_mode(group, ssm): if group.version == 4: return group.is_multicast and (group.packed[0] == 232) == ssm p = group.packed return p[0] == 0xFF and (p[1] >> 4 == 3 and p[2] == 0 and p[3] == 0) == ssm def read_endpoint(e, tracks, auth): """bearer-receipts §9.7, in its order: "stands" or the label the endpoint is ignored with.""" if not isinstance(e, dict) or not isinstance(e.get("groupAddress"), str) or not isinstance(e.get("tracks"), list) or not e["tracks"] \ or any(not isinstance(t, dict) for t in e["tracks"]) or any(k in e and not isinstance(e[k], str) for k in ("sourceAddress", "protocol")): return "endpoint_malformed" bw = e.get("bandwidth") if "bandwidth" in e and (isinstance(bw, bool) or not isinstance(bw, int) or not 0 <= bw < 1 << 53): return "endpoint_malformed" try: group = ipaddress.ip_address(e["groupAddress"]) source = ipaddress.ip_address(e["sourceAddress"]) if "sourceAddress" in e else None except ValueError: return "endpoint_address" if (source is not None and source.version != group.version) or not in_mode(group, source is not None): return "endpoint_address" if "protocol" in e and e["protocol"] != ("ssm" if source is not None else "asm"): return "endpoint_mode" port = e.get("port") if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: return "endpoint_port" for t in e["tracks"]: pid = t.get("packetId") if not isinstance(t.get("name"), str) or isinstance(pid, bool) or not isinstance(pid, int) or not 1 <= pid <= 65535: return "endpoint_packet_id" def find(name): got = [t for t in tracks if isinstance(t, dict) and t.get("name") == name] return got[0] if len(got) == 1 else None for t in e["tracks"]: tr = find(t["name"]) if tr is None or "namespace" in tr: return "endpoint_track" if tr.get("packaging") == "mmtp": continue src = find(tr.get("source")) if tr.get("packaging") == "fec-repair" and isinstance(tr.get("source"), str) else None if src is None or src.get("packaging") != "mmtp": return "endpoint_track" if ("auth" in e) != auth or ("auth" in e and "bandwidth" not in e): return "endpoint_auth" return "stands" def read_endpoints(catalog): if "multicast" not in catalog: return [] mc = catalog["multicast"] eps = mc.get("endpoints") if isinstance(mc, dict) else None if not isinstance(eps, list) or not eps: return "endpoints_malformed" tracks = catalog.get("tracks") if isinstance(catalog.get("tracks"), list) else [] return [read_endpoint(e, tracks, "auth" in mc) for e in eps] def outcome(f, *a): try: return f(*a) except Refuse as r: return str(r) def check(doc): fails = [] for g in doc["groups"]: what = g["what"] got = outcome(bind, g["entries"], g["first_ss_id"]) if isinstance(got, str): fails.append("%s: does not bind: %s" % (what, got)) continue instances, objects = got fec, tracks = catalog_tracks(instances, g["track"]) if (fec, tracks) != (g["fec"], g["tracks"]): fails.append("%s: the catalog tracks are not the ones stated" % what) read = outcome(read_catalog, [{"name": g["track"], "packaging": "wai", "isLive": True, "fec": g["fec"]}] + g["tracks"], g["track"]) if read != instances or not same_instances(instances, read): fails.append("%s: the catalog does not read back to the schedule's instances: %s" % (what, read)) for o, w, e, env in zip(objects, g["objects"], g["entries"], g["envelopes"]): inst = instances[o["instance"]] t = inst["symbol_size"] at = "%s, object %d" % (what, o["leaf_index"]) if any(o[k] != w[k] for k in o): fails.append("%s: its instance or block is not the one stated" % at) continue payload = bytes.fromhex(env) imm = immutable_properties(o["instance"], o["ss_id"]) u = unit(g["group_id"], o["leaf_index"], imm, payload) src = symbols(u, t) if imm.hex() != w["immutable"] or u.hex() != w["unit"] or [s.hex() for s in src] != w["symbol_track"] or len(src) != o["symbols"]: fails.append("%s: its unit or its source symbols are not the ones stated" % at) # Everything §6 derives from the entry holds of the block: the count a pass, and K'. k = ceil_div(e["oti"]["transfer_length"], t) if o["symbols"] + inst["layers"][0] != k + e["repair"] or k_prime(o["symbols"]) != k_prime(k): fails.append("%s: a pass does not send the schedule's symbols" % at) # The repair objects' headers, Locations and layers. block = {"group": o["block_group"], "ssb": None} total = sum(inst["layers"]) if len(w["repair"]) != total: fails.append("%s: %d repair objects for %d repair symbols" % (at, len(w["repair"]), total)) for rs, r in enumerate(w["repair"]): layer = max(x for x in range(len(inst["layers"])) if sum(inst["layers"][:x]) <= rs) hdr = read_repair(bytes.fromhex(r["hex"]), t) if (r["layer"], r["group"], r["object"]) != (layer, o["block_group"], rs - sum(inst["layers"][:layer])) or hdr is None \ or (hdr["ss_start"], hdr["rsb"], hdr["rs_id"], hdr["ssb"]) != (o["ss_id"], total, rs, o["symbols"]) \ or not accept_repair(inst, block, r["layer"], r["group"], r["object"], bytes.fromhex(r["hex"])): fails.append("%s: repair object %d is not where §8.3 puts it" % (at, rs)) block["ssb"] = o["symbols"] # Rebuilt from its symbol track, it is the object its entry signs. held = b"".join(bytes.fromhex(s) for s in w["symbol_track"]) got = outcome(rebuild, held, inst, o["ss_id"]) if isinstance(got, str) or len(got) != 1 or got[0][3] != payload or outcome(check_object, got[0], g["group_id"], e) is not None: fails.append("%s: it does not rebuild to the object scheduled" % at) # And back: the entry's code members from the instance and the block. if (inst["symbol_size"], o["symbols"] + inst["layers"][0] - k) != (e["oti"]["symbol_size"], e["repair"]): fails.append("%s: the entry's members do not come back" % at) # A symbol of another object, in the ways that need no decoding. first = [i for i, o in enumerate(objects) if o["instance"] == objects[0]["instance"]] if len(first) >= 2: a, b = objects[first[0]], objects[first[1]] wa, wb = g["objects"][first[0]], g["objects"][first[1]] inst = instances[a["instance"]] block = {"group": a["block_group"], "ssb": a["symbols"]} other = bytes.fromhex(wb["repair"][0]["hex"]) forged = a["ss_id"].to_bytes(4, "big") + other[4:] if accept_symbol(inst, block, b["block_group"], 0, bytes.fromhex(wb["symbol_track"][0])) or accept_repair(inst, block, 0, b["block_group"], 0, other) \ or accept_repair(inst, block, 0, a["block_group"], 0, other) or accept_repair(inst, block, 0, a["block_group"], 0, forged) \ or accept_symbol(inst, block, a["block_group"], a["symbols"], bytes.fromhex(wa["symbol_track"][0])): fails.append("%s: a symbol of another object was placed" % what) for esi, want in ((0, "binding_unit_malformed"), (min(6, a["symbols"] - 2), "binding_content_mismatch")): held = [bytes.fromhex(s) for s in wa["symbol_track"]] held[esi] = bytes.fromhex(wb["symbol_track"][esi]) got = outcome(rebuild, b"".join(held), inst, a["ss_id"]) if not isinstance(got, str): got = outcome(check_object, got[0], g["group_id"], g["entries"][first[0]]) if got != want: fails.append("%s: the other object's symbol %d in place gave %s, not %s" % (what, esi, got, want)) for c in doc["unbound"]: got = outcome(bind, [c["entry"]], 0) if got != c["reason"]: fails.append("unbound %r: %s, stated %s" % (c["what"], got if isinstance(got, str) else "bound", c["reason"])) for c in doc["catalogs"]: got = outcome(read_catalog, c["tracks"], c["tracks"][0]["name"]) got = got if isinstance(got, str) else "accept" if got != c["outcome"]: fails.append("catalog %r: %s, stated %s" % (c["what"], got, c["outcome"])) for c in doc["endpoints"]: got = read_endpoints(c["catalog"]) if got != c["outcome"]: fails.append("endpoints %r: %s, stated %s" % (c["what"], got, c["outcome"])) if len(doc["groups"]) < 3 or len(doc["unbound"]) < 9 or len(doc["catalogs"]) < 30 or len(doc["endpoints"]) < 6: fails.append("the corpus is smaller than it was written to be") return fails def main(argv): d = argv[1] if len(argv) > 1 else os.path.join(HERE, "carousel-moq-fec") with open(os.path.join(d, "vectors.json"), encoding="utf-8") as fh: doc = json.load(fh) if K_PRIME[0] != 10 or K_PRIME[-1] != K_MAX or len(K_PRIME) != 477: print("FAIL carousel.md Appendix A is not the table of K'") return 1 fails = check(doc) def flip(h): return ("00" if h[:2] != "00" else "01") + h[2:] edits = [ ("an envelope octet", lambda x: x["groups"][0]["envelopes"].__setitem__(0, flip(x["groups"][0]["envelopes"][0]))), ("a symbol-track object", lambda x: x["groups"][0]["objects"][1]["symbol_track"].__setitem__(3, flip(x["groups"][0]["objects"][1]["symbol_track"][3]))), ("an SS_ID", lambda x: x["groups"][0]["objects"][1].__setitem__("ss_id", x["groups"][0]["objects"][1]["ss_id"] + 1)), ("a repair object's RS_ID", lambda x: x["groups"][0]["objects"][0]["repair"][2].__setitem__("hex", x["groups"][0]["objects"][0]["repair"][3]["hex"])), ("a repair object's layer", lambda x: x["groups"][1]["objects"][0]["repair"][8].__setitem__("layer", 0)), ("an entry's repair", lambda x: x["groups"][0]["entries"][0].__setitem__("repair", 10)), ("an entry's content hash", lambda x: x["groups"][2]["entries"][0].__setitem__("content_hash", "00" * 32)), ("the catalog's repairSymbols", lambda x: x["groups"][0]["fec"][0].__setitem__("repairSymbols", 8)), ("a repair track's layer", lambda x: x["groups"][1]["tracks"][2].__setitem__("repairLayer", 2)), ("an unbound entry's reason", lambda x: x["unbound"][0].__setitem__("reason", "binding_symbol_size")), ("an unbound entry made good", lambda x: x["unbound"][4].__setitem__("entry", x["groups"][0]["entries"][0])), ("a catalog's outcome", lambda x: x["catalogs"][5].__setitem__("outcome", "accept")), ("a good catalog's outcome", lambda x: x["catalogs"][0].__setitem__("outcome", "binding_catalog_malformed")), ("an endpoint's outcome", lambda x: x["endpoints"][0]["outcome"].__setitem__(0, "endpoint_port")), ("an ignored endpoint stated to stand", lambda x: x["endpoints"][0]["outcome"].__setitem__(11, "stands")), ] for what, e in edits: changed = copy.deepcopy(doc) e(changed) if not check(changed): fails.append("a change went unnoticed: %s" % what) for f in fails: print("FAIL " + f) n = sum(len(g["objects"]) for g in doc["groups"]) print("carousel-moq-fec: %d objects of %d groups, %d unbound, %d catalogs, %d endpoint lists, %d edits; %d failure(s)" % (n, len(doc["groups"]), len(doc["unbound"]), len(doc["catalogs"]), len(doc["endpoints"]), len(edits), len(fails))) return 1 if fails else 0 if __name__ == "__main__": sys.exit(main(sys.argv))