#!/usr/bin/env python3 """Independent verifier of the SDP capability attributes of a WHEP offer (extensions/live-ingest.md §9.5). Written from §9.5's text and the grammars it cites (RFC 5939 §3.3 and §3.5, RFC 6871 §3.3 and §3.4); it shares no code with wai-rs's sdp_caps, which the corpus is checked against too. It reads from §9.5 the table of the capabilities a gateway carries over WebRTC, and from §14 the reasons. For every case of sdp-caps/vectors.json it recomputes: - `reads`: the offer read or refused, with the refusal's reason; for an offer read, each media section's advertised WAI capabilities in order, each with its configuration, media capability and payload type; - `picks`: the rendition a gateway forwards for a section, or the floor, and the answer's `a=acfg` line; - `writes`: the offer with a sink's capabilities written into it, byte for byte, the capabilities left out, and that reading it back gives the capabilities written, in order. It requires every reason of §9.5 to appear in the corpus and §14 to list each. Then it mutates copies of the corpus, one defect each, and requires every copy to fail. python3 -I sdp_caps_verify.py Exit status 0 only if every check holds and every mutated copy fails. Python 3.8 or later; standard library only. """ import copy import json import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) WAI = os.path.normpath(os.path.join(HERE, "..")) def read_text(p): with open(p, encoding="utf-8") as f: return f.read() LI = read_text(os.path.join(WAI, "extensions", "live-ingest.md")) def section(text, heading, stop): i = text.index(heading) return text[i:text.index(stop, i + len(heading))] def ticks(s): return re.findall(r"`([^`]*)`", s) def rtp_rows(): """§9.5's table: capability, encoding name, clock rate, encoding parameters.""" rows = [] for line in section(LI, "### 9.5 ", "## 10.").splitlines(): cells = [c.strip() for c in line.strip().strip("|").split("|")] if line.startswith("| `wai.") and len(cells) == 4: params = ticks(cells[3]) rows.append((ticks(cells[0])[0], ticks(cells[1])[0], int(ticks(cells[2])[0]), params[0] if params else None)) assert rows, "§9.5 has no table" return rows def reasons_95(): out = [] for line in section(LI, "### 9.5 ", "## 10.").splitlines(): if line.startswith("| `caps_"): out.append(ticks(line)[0]) return out def reasons_14(): out = set() for line in section(LI, "## 14. Reasons", "## 15.").splitlines(): if line.startswith("| `"): out.update(ticks(line.split("|")[1])) return out ROWS = rtp_rows() REASONS = reasons_95() MAX_CAPS = 1024 class Refusal(Exception): def __init__(self, reason, detail): Exception.__init__(self, "%s: %s" % (reason, detail)) self.reason = reason TOKEN = set(b"!#$%&'*+-.^_`{|}~") | set(range(0x30, 0x3A)) | set(range(0x41, 0x5B)) | set(range(0x61, 0x7B)) def is_token(s): return bool(s) and all(c in TOKEN for c in s.encode("latin-1", "replace")) def num(s, nonzero=False): if not s or len(s) > 10 or not s.isdigit() or not all(c in "0123456789" for c in s): return None if nonzero and s[0] == "0": return None v = int(s) return v if 1 <= v <= 2 ** 31 - 1 else None def cap_list(s): out = [] for el in s.split(","): if "-" in el: a, b = el.split("-", 1) a, b = num(a, True), num(b, True) if a is None or b is None or a >= b: raise Refusal("caps_malformed", "range " + el) if b - a >= MAX_CAPS: raise Refusal("caps_too_many", "range " + el) out.extend(range(a, b + 1)) else: n = num(el, True) if n is None: raise Refusal("caps_malformed", "number " + el) out.append(n) return out def wsp(s): m = re.match(r"([^ \t]*)[ \t]+(.+)$", s, re.S) return (m.group(1), m.group(2)) if m and m.group(1) else None def att_config(val): """RFC 5939 attribute-config-list after its "a="; None when it is off the grammar.""" dels = ("-m", "-s", "-ms") if ":" in val: d, lists = val.split(":", 1) if d not in dels: return None elif val in dels: return [] else: lists = val out = [] for alt in lists.split("|"): m = re.fullmatch(r"(\d+(?:,\d+)*)?(?:(?(1),)\[(\d+(?:,\d+)*)\])?", alt) if not m or not alt: return None for g in (m.group(1), m.group(2)): for n in (g.split(",") if g else []): if num(n) is None: return None out.append(num(n)) return out def pcfg(number, rest): cfg = {"number": number, "media": [], "pt": [], "skip": False} a_refs, t_refs, seen = [], [], set() for item in [x for x in re.split(r"[ \t]+", rest) if x]: plus = item.startswith("+") body = item[1:] if plus else item if "=" not in body: raise Refusal("caps_malformed", item) key, val = body.split("=", 1) if key in ("a", "t", "m", "pt"): if key in seen: raise Refusal("caps_malformed", "a second " + key) seen.add(key) if key == "a" and not plus: got = att_config(val) if got is None: raise Refusal("caps_malformed", item) a_refs += got cfg["skip"] = True elif key == "t" and not plus: for n in val.split("|"): if num(n) is None: raise Refusal("caps_malformed", item) t_refs.append(num(n)) cfg["skip"] = True elif key == "m": cfg["media"] = [cap_list(alt) for alt in val.split("|")] elif key == "pt": for mp in val.split(","): if ":" not in mp: raise Refusal("caps_malformed", item) m, p = mp.split(":", 1) m, pv = num(m, True), num(p, True) if m is None or pv is None or len(p) > 3: raise Refusal("caps_malformed", item) if pv > 127: raise Refusal("caps_payload_type", item) if any(q == pv or c == m for c, q in cfg["pt"]): raise Refusal("caps_payload_type", item) cfg["pt"].append((m, pv)) else: if not key or not all(c.isalnum() and c.isascii() for c in key) or not val or not all(0x21 <= ord(c) <= 0x7E for c in val): raise Refusal("caps_malformed", item) if plus: cfg["skip"] = True return cfg, a_refs, t_refs def sdp_lines(sdp): return [l[:-1] if l.endswith("\r") else l for l in sdp.split("\n") if l not in ("", "\r")] def read(sdp): options, required, caps, mf, acap, tcap, sections, cfgnums, pending, creq_at = set(), [], {}, [], {}, {}, [], set(), [], set() for line in sdp_lines(sdp): if line.startswith("m="): sections.append({"media": line[2:].split(" ")[0], "configs": []}) continue if not line.startswith("a="): continue attr = line[2:] name, value = attr.split(":", 1) if ":" in attr else (attr, "") here = len(sections) - 1 if sections else None if name in ("csup", "creq"): tags = value.split(",") if not all(is_token(t) for t in tags): raise Refusal("caps_malformed", value) if name == "creq": if here in creq_at: raise Refusal("caps_malformed", "a second creq at one level") creq_at.add(here) required += tags options.update(tags) elif name in ("rmcap", "omcap"): sp = wsp(value) if not sp: raise Refusal("caps_malformed", attr) nums = cap_list(sp[0]) if name == "rmcap": parts = sp[1].split("/") if len(parts) not in (2, 3) or not is_token(parts[0]) or (len(parts) == 3 and not is_token(parts[2])): raise Refusal("caps_malformed", attr) rate = num(parts[1], True) if rate is None: raise Refusal("caps_malformed", attr) entry = (True, parts[0], rate, parts[2] if len(parts) == 3 else None) else: if not is_token(sp[1]): raise Refusal("caps_malformed", attr) entry = (False, sp[1], None, None) for n in nums: if n in caps: raise Refusal("caps_number_reused", str(n)) caps[n] = {"rtp": entry[0], "name": entry[1], "rate": entry[2], "params": entry[3], "level": here, "fmtp": []} if len(caps) > MAX_CAPS: raise Refusal("caps_too_many", "count") elif name == "mfcap": sp = wsp(value) if not sp: raise Refusal("caps_malformed", attr) mf.append((cap_list(sp[0]), sp[1])) elif name in ("acap", "tcap"): sp = wsp(value) if not sp or num(sp[0]) is None: raise Refusal("caps_malformed", attr) ok = is_token(sp[1].split(":")[0]) if name == "acap" else all(is_token(t) for p in re.split(r"[ \t]+", sp[1]) if p for t in p.split("/")) if not ok: raise Refusal("caps_malformed", attr) table = acap if name == "acap" else tcap if num(sp[0]) in table: raise Refusal("caps_number_reused", attr) table[num(sp[0])] = here elif name == "pcfg": if here is None: raise Refusal("caps_malformed", "pcfg at the session level") sp = wsp(value) n_s, rest = sp if sp else (value, "") n = num(n_s) if n is None: raise Refusal("caps_malformed", attr) if n in cfgnums: raise Refusal("caps_config_reused", str(n)) cfgnums.add(n) cfg, ar, tr = pcfg(n, rest) pending.append((here, cfg, ar, tr)) elif name in ("sescap", "lcfg"): raise Refusal("caps_unsupported", name) if caps and "med-v0" not in options: raise Refusal("caps_option_missing", "no med-v0") for t in required: if t not in ("cap-v0", "med-v0"): raise Refusal("caps_unsupported", t) def vis(level, sec): return level is None or level == sec for nums, params in mf: for n in nums: if n not in caps: raise Refusal("caps_unknown_capability", "mfcap %d" % n) caps[n]["fmtp"].append(params) for sec, cfg, ar, tr in pending: for alt in cfg["media"]: for n in alt: if n not in caps or not vis(caps[n]["level"], sec): raise Refusal("caps_unknown_capability", "m %d" % n) if caps[n]["rtp"] and n not in [c for c, _ in cfg["pt"]]: raise Refusal("caps_payload_type", "no pt for %d" % n) for c, _ in cfg["pt"]: if c not in caps or not vis(caps[c]["level"], sec): raise Refusal("caps_unknown_capability", "pt %d" % c) for n in ar: if n not in acap or not vis(acap[n], sec): raise Refusal("caps_unknown_capability", "a %d" % n) for n in tr: if n not in tcap or not vis(tcap[n], sec): raise Refusal("caps_unknown_capability", "t %d" % n) sections[sec]["configs"].append(cfg) return {"caps": caps, "sections": sections} def wai_cap(c): if not c["rtp"]: return None for cap, enc, rate, params in ROWS: if enc.lower() == c["name"].lower() and rate == c["rate"] and params == c["params"]: return cap return None def advertised(offer, sec): out = [] for cfg in sorted((c for c in offer["sections"][sec]["configs"] if not c["skip"]), key=lambda c: c["number"]): for alt in cfg["media"]: for n in alt: cap = wai_cap(offer["caps"][n]) if cap is None or any(a["capability"] == cap for a in out): continue pt = [p for c, p in cfg["pt"] if c == n][0] out.append({"capability": cap, "config": cfg["number"], "cap": n, "payload_type": pt}) return out def pick(adv, renditions, max_layer, floor): for a in adv: rs = [r for r in renditions if r["layer"] <= max_layer and r["capability"] == a["capability"]] if rs: r = min(rs, key=lambda r: r["layer"]) return {"layer": r["layer"], "capability": r["capability"], "config": a["config"], "cap": a["cap"], "payload_type": a["payload_type"], "acfg": "a=acfg:%d m=%d pt=%d:%d" % (a["config"], a["cap"], a["cap"], a["payload_type"])} return {"layer": floor, "floor": True, "acfg": None} CAPS_ATTRS = ("a=csup:", "a=creq:", "a=rmcap:", "a=omcap:", "a=mfcap:", "a=pcfg:", "a=acap:", "a=tcap:") def kind(cap): return "audio" if cap.startswith("wai.audio.") else "video" def write(offer, wanted): eol = "\r\n" if "\r\n" in offer else "\n" ls = sdp_lines(offer) if any(l.startswith(CAPS_ATTRS) for l in ls): return None, "caps_present" ms = [i for i, l in enumerate(ls) if l.startswith("m=")] if not ms: return None, "no_media" out = ls[:ms[0]] + ["a=csup:med-v0"] used, n, c = set(), 1, 1 for k, st in enumerate(ms): sec = ls[st:ms[k + 1] if k + 1 < len(ms) else len(ls)] out += sec parts = sec[0][2:].split(" ") for cap in wanted: row = [r for r in ROWS if r[0] == cap] if not row or kind(cap) != parts[0]: continue _, enc, rate, params = row[0] hit = None for pt in parts[3:]: rm = [l[len("a=rtpmap:%s " % pt):] for l in sec if l.startswith("a=rtpmap:%s " % pt)] if rm: p = rm[0].split("/") if len(p) >= 2 and p[0].lower() == enc.lower() and p[1] == str(rate) and (p[2] if len(p) > 2 else None) == params: hit = pt break if hit is None: continue used.add(cap) out.append("a=rmcap:%d %s/%d%s" % (n, enc, rate, "/" + params if params else "")) fm = [l[len("a=fmtp:%s " % hit):] for l in sec if l.startswith("a=fmtp:%s " % hit)] if fm: out.append("a=mfcap:%d %s" % (n, fm[0])) out.append("a=pcfg:%d m=%d pt=%d:%s" % (c, n, n, hit)) n += 1 c += 1 return eol.join(out) + eol, [w for w in wanted if w not in used] def verify(doc): fails, seen = [], set() for c in doc["reads"]: try: o = read(c["offer"]) got = {"outcome": "read", "sections": [{"media": s["media"], "advertised": advertised(o, i)} for i, s in enumerate(o["sections"])]} except Refusal as e: got = {"outcome": "refused", "reason": e.reason} seen.add(e.reason) want = {k: c[k] for k in ("outcome", "sections", "reason") if k in c} if got != want: fails.append("read %s: %s" % (c["name"], json.dumps(got)[:300])) for c in doc["picks"]: try: o = read(c["offer"]) except Refusal as e: fails.append("pick %s: the offer is refused (%s)" % (c["name"], e)) continue got = pick(advertised(o, c["section"]), c["renditions"], c["max_layer"], c["floor_layer"]) if got != c["pick"]: fails.append("pick %s: %s" % (c["name"], json.dumps(got))) for c in doc["writes"]: out, left = write(c["offer"], c["capabilities"]) if out is None: if c.get("refused") != left: fails.append("write %s: refused %s" % (c["name"], left)) continue if out != c["written"] or left != c["left_out"]: fails.append("write %s: %s" % (c["name"], json.dumps([out, left])[:300])) continue o = read(out) for i, s in enumerate(o["sections"]): back = [a["capability"] for a in advertised(o, i)] if back != [w for w in c["capabilities"] if w not in left and kind(w) == s["media"]]: fails.append("write %s: section %d read back as %s" % (c["name"], i, back)) for r in REASONS: if r not in seen: fails.append("nothing in the corpus gives %s" % r) return fails def mutations(doc): def find(d, fam, name): return next(c for c in d[fam] if c["name"] == name) out = [] for c in doc["reads"]: if c["outcome"] == "refused": out.append(("%s read as another refusal" % c["name"], lambda d, n=c["name"]: find(d, "reads", n).update(reason="caps_other"))) else: out.append(("%s's sections reordered" % c["name"], lambda d, n=c["name"]: find(d, "reads", n)["sections"].reverse() if len(find(d, "reads", n)["sections"]) > 1 else find(d, "reads", n).update(outcome="refused", reason="caps_malformed"))) if any(s["advertised"] for s in c["sections"]): out.append(("%s's first advertised capability dropped" % c["name"], lambda d, n=c["name"]: [s["advertised"].pop(0) for s in find(d, "reads", n)["sections"] if s["advertised"]][:1])) for c in doc["picks"]: out.append(("%s's layer moved" % c["name"], lambda d, n=c["name"]: find(d, "picks", n)["pick"].update(layer=find(d, "picks", n)["pick"]["layer"] + 1))) for c in doc["writes"]: if c.get("written"): out.append(("%s's text changed" % c["name"], lambda d, n=c["name"]: find(d, "writes", n).update(written=find(d, "writes", n)["written"].replace("a=pcfg:1 ", "a=pcfg:2 ")))) kept = [w for w in c["capabilities"] if w not in c["left_out"]] if any(sum(1 for w in kept if kind(w) == k) > 1 for k in ("audio", "video")): out.append(("%s's order swapped" % c["name"], lambda d, n=c["name"]: find(d, "writes", n)["capabilities"].reverse())) return out def main(): if len(sys.argv) != 2: print(__doc__) return 2 with open(os.path.join(sys.argv[1], "vectors.json"), encoding="utf-8") as f: doc = json.load(f) bad = 0 missing = [r for r in REASONS if r not in reasons_14()] if missing or not REASONS: print("FAIL §14 does not list %s" % missing) bad += 1 fails = verify(doc) for f in fails: print("FAIL " + f) n = sum(len(doc[k]) for k in ("reads", "picks", "writes")) print("%s every case gets the outcome the corpus states (%d cases; every reason of §9.5 given)" % ("PASS" if not fails else "FAIL", n)) bad += bool(fails) muts = mutations(doc) caught = 0 for name, change in muts: d2 = copy.deepcopy(doc) change(d2) if verify(d2): caught += 1 else: print("FAIL the mutation '%s' is not caught" % name) print("%s %d of %d mutated copies fail" % ("PASS" if caught == len(muts) else "FAIL", caught, len(muts))) bad += caught != len(muts) return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())