All checks were successful
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 7s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 15s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 7s
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 7s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 14s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 8s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 6s
WAI conformance / does this change touch it (pull_request) Successful in 8s
WAI energy receipts / does this change touch it (pull_request) Successful in 6s
WAI operation catalogue / does this change touch it (pull_request) Successful in 16s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 7s
wai-fleet service / does this change touch it (pull_request) Successful in 6s
wai-ledger service / does this change touch it (pull_request) Successful in 6s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 8s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 7s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 7s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 13s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 7s
WAI Studio / does this change touch it (pull_request) Successful in 6s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 7s
wai-web wasm32 / does this change touch it (pull_request) Successful in 13s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 3m43s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 29s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m10s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 3m2s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 2m54s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m39s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 1m3s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m20s
wai-fleet service / service · linux (pull_request) Successful in 1m59s
wai-pack / wai-pack · linux (pull_request) Successful in 3m21s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m39s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 50s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 4m44s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 4m8s
wai-ledger service / service · linux (pull_request) Successful in 4m30s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 2m46s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 2m26s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 9m36s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 5m53s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 4m46s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 6m10s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 15m48s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 13m24s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 12m43s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 23m53s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m16s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 4m0s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 19s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 43s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 6m29s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m25s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 7m16s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 27s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m9s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 12m29s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 10m34s
wai-ledger service / service · macos (pull_request) Successful in 48s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 16m47s
wai-fleet service / service · macos (pull_request) Successful in 1m3s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 29s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 11m30s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 34s
wai-pack / wai-pack · macos (pull_request) Successful in 2m30s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 7m25s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 1m52s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 10m38s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 4m50s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 4m58s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m54s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m44s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 25s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 17s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 15m5s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 7m37s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 11m59s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 2m23s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 15m57s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 9m16s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h13m0s
The change of wai/hls-cues-delivery-decision (98192fe63..c42fa8c9c, 4 commits), on the fix branch: its changelog entries as files of wai/changes/, its wai-conformance.yml edits as step files, corpus files and test tags, and every other file as the branch has it. - 8b9a32067 channel-lineup §7.4: a channel's breaks marked in its HLS Media Playlist, from its signed schedule - af5947f31 route-record §6.3: a delivery's decision records why it took the route it took - 558a6764f channel-lineup §7.4: a window ends before the year 10000, and every rule of the cue tags has a vector - c42fa8c9c route-record §6.3: a control for each member that binds a decision to its documents, and what the chain proves Co-Authored-By: Claude Opus 5.5 <[email protected]>
1683 lines
70 KiB
Python
1683 lines
70 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier of the channel lineup corpus (extensions/channel-lineup.md).
|
|
|
|
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
|
and never runs it. Ed25519 signatures are checked with ../claim-conformance/_ed25519.py, WAI's
|
|
strict rule (SPEC §7 *Signatures*), and BLAKE3 with ../claim-conformance/_blake3.py. From the
|
|
corpora's other second implementations it takes, by reference as the extension does: a channel
|
|
receipt's JSON carriage, its leaves and schedule root, its signed bytes, its identity and its
|
|
rules (revenue-share §4, the extension's §5) from revenue_share_verify.py; and the XML subset, the
|
|
HLS attribute lists and the decimal reading of stitched-presentation §3.2 and §3.5 from
|
|
stitched_presentation_verify.py. Everything else is here, from the standard library:
|
|
|
|
- text values, XMLTV and HLS times, durations, and the document type declaration (§3);
|
|
- the import of an XMLTV schedule, Media RSS catalogues, the JSON form and a Media Playlist's
|
|
breaks into a schedule, its assets and its cues (§4);
|
|
- the lineup, its rules, digest and signature, and whether it describes a schedule (§6);
|
|
- the XMLTV guide and the now/next document (§7), and verification (§8);
|
|
- the cue tags of a Media Playlist (§7.4): the breaks and their cues, a playlist's window, its
|
|
alignment and its lines; and a playlist's verification (§8.4).
|
|
|
|
For every import it requires the outcome the corpus states (the refusal, or a receipt, lineup and
|
|
cues it recomputes and whose signatures it verifies); for every verification, the facts; for every
|
|
playlist window, the playlist byte for byte or the refusal; and for every playlist verification,
|
|
the facts. Of the
|
|
corpus, that every reason of §10 and both of live-events' appear, and that no other file is
|
|
there. 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 channel_lineup_verify.py <live-conformance/channels>
|
|
|
|
Exit status 0 only if every vector verifies, every mutated copy fails and every weakened verifier
|
|
is caught. Python 3.8 or later; standard library only.
|
|
"""
|
|
|
|
import copy
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
|
import _blake3 as B3 # noqa: E402
|
|
import _ed25519 as ED # noqa: E402
|
|
import revenue_share_verify as RSV # noqa: E402
|
|
import stitched_presentation_verify as SPV # noqa: E402
|
|
|
|
EXTENSION = os.path.join(HERE, "..", "extensions", "channel-lineup.md")
|
|
|
|
MAGIC, VERSION = b"WLU1", 1 # §6.1
|
|
LINEUP_DOMAIN = b"wai:channel-lineup\x01" # §6.3
|
|
CUE_ID_DOMAIN = b"wai:channel-cue-id\x01" # §4.8
|
|
ID_SYSTEM = "wai-asset-id" # §7.2
|
|
GUIDE_URN = "urn:wai:guide:"
|
|
NOW_NEXT_KIND, FEED_KIND = "wai-now-next", "wai-channel-feed"
|
|
MAX_DOC, MAX_COUNT, MAX_INSTANT = 16777216, 10000, 2 ** 53 - 1 # §2, §3.1
|
|
U32, U64 = 2 ** 32 - 1, 2 ** 64 - 1
|
|
EVERYWHERE, ONLY, EXCEPT = 0, 1, 2 # §6.2 geo
|
|
DIGITS = "0123456789"
|
|
WS = " \t\r\n"
|
|
|
|
# The checks a weakened verifier may drop (main(), the last part).
|
|
SKIP = set()
|
|
|
|
|
|
class Refuse(Exception):
|
|
def __init__(self, reason):
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
|
|
|
|
def check(name, ok, reason):
|
|
"""A rule: refuse `reason` when it fails, unless this run drops the check `name`."""
|
|
if not ok and name not in SKIP:
|
|
raise Refuse(reason)
|
|
|
|
|
|
def sha(*parts):
|
|
h = hashlib.sha256()
|
|
for p in parts:
|
|
h.update(p)
|
|
return h.digest()
|
|
|
|
|
|
def reasons():
|
|
"""§10's table of the codes this extension adds, and the codes it names after it."""
|
|
with open(EXTENSION, encoding="utf-8") as f:
|
|
text = f.read()
|
|
sec = text[text.index("\n## 10. Reasons"):]
|
|
sec = sec[:sec.index("\n## ", 1)]
|
|
table, after = sec.split("\nA verification also", 1)
|
|
added = [ln.split("`")[1] for ln in table.splitlines() if ln.startswith("| `")]
|
|
reused = [w.strip("`") for w in after.split() if w.startswith("`") and w.rstrip(",.").endswith("`")]
|
|
return added, [r.rstrip("`") for r in reused]
|
|
|
|
|
|
# ---------------------------------------------------------------- §3.2 text values
|
|
|
|
def is_text(s):
|
|
return isinstance(s, str) and all(c >= " " or c in "\t\n" for c in s) and not any("\ud800" <= c <= "\udfff" for c in s)
|
|
|
|
|
|
def blen(s):
|
|
return len(s.encode("utf-8"))
|
|
|
|
|
|
def short_text(s):
|
|
return is_text(s) and 1 <= blen(s) <= 255
|
|
|
|
|
|
def long_text(s):
|
|
return is_text(s) and blen(s) <= 65535
|
|
|
|
|
|
def field(ok):
|
|
check("fields", ok, "import_field_invalid")
|
|
|
|
|
|
# ---------------------------------------------------------------- §3.4 times
|
|
|
|
def instant(y, mo, d, h, mi, s, ms, offset_min):
|
|
if y < 1970 or not 1 <= mo <= 12 or h > 23 or mi > 59 or s > 59:
|
|
return None
|
|
try:
|
|
days = (datetime.date(y, mo, d) - datetime.date(1970, 1, 1)).days
|
|
except ValueError:
|
|
return None
|
|
t = (days * 86400 + h * 3600 + mi * 60 + s - offset_min * 60) * 1000 + ms
|
|
return t if t >= 0 else None
|
|
|
|
|
|
def digits(s):
|
|
return len(s) > 0 and all(c in DIGITS for c in s)
|
|
|
|
|
|
def xmltv_time(v):
|
|
"""§3.4: an XMLTV time's instant, or Refuse."""
|
|
if len(v) < 14 or not digits(v[:14]):
|
|
raise Refuse("import_time_malformed")
|
|
rest = v[14:]
|
|
if rest == "":
|
|
off = 0
|
|
elif rest.startswith(" "):
|
|
z = rest[1:]
|
|
ok = len(z) == 5 and z[0] in "+-" and digits(z[1:]) and int(z[1:3]) <= 23 and int(z[3:5]) <= 59
|
|
check("timezone", ok, "import_timezone_unknown")
|
|
if not ok:
|
|
off = 0
|
|
else:
|
|
off = (int(z[1:3]) * 60 + int(z[3:5])) * (-1 if z[0] == "-" else 1)
|
|
else:
|
|
raise Refuse("import_time_malformed")
|
|
t = instant(int(v[0:4]), int(v[4:6]), int(v[6:8]), int(v[8:10]), int(v[10:12]), int(v[12:14]), 0, off)
|
|
if t is None:
|
|
raise Refuse("import_time_malformed")
|
|
return t
|
|
|
|
|
|
def xmltv_format(ms):
|
|
d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms)
|
|
return d.strftime("%Y%m%d%H%M%S") + " +0000"
|
|
|
|
|
|
def hls_date_time(v):
|
|
"""§3.4: an HLS date-time's instant, or Refuse."""
|
|
bad = Refuse("import_time_malformed")
|
|
shape = "dddd-dd-ddTdd:dd:dd"
|
|
if len(v) < 19 or any((c not in DIGITS) if p == "d" else (c != p) for p, c in zip(shape, v[:19])):
|
|
raise bad
|
|
p, ms = 19, 0
|
|
if p < len(v) and v[p] == ".":
|
|
q = p + 1
|
|
while q < len(v) and v[q] in DIGITS:
|
|
q += 1
|
|
frac = v[p + 1:q]
|
|
if not frac or frac[3:].strip("0"):
|
|
raise bad
|
|
ms = int((frac[:3] + "000")[:3])
|
|
p = q
|
|
z = v[p:]
|
|
if z == "":
|
|
raise Refuse("import_timezone_unknown")
|
|
if z == "Z":
|
|
off = 0
|
|
elif len(z) == 6 and z[0] in "+-" and z[3] == ":" and digits(z[1:3]) and digits(z[4:6]):
|
|
hh, mm = int(z[1:3]), int(z[4:6])
|
|
if hh > 23 or mm > 59:
|
|
raise bad
|
|
off = (hh * 60 + mm) * (-1 if z[0] == "-" else 1)
|
|
else:
|
|
raise bad
|
|
t = instant(int(v[0:4]), int(v[5:7]), int(v[8:10]), int(v[11:13]), int(v[14:16]), int(v[17:19]), ms, off)
|
|
if t is None or t > MAX_INSTANT:
|
|
raise bad
|
|
return t
|
|
|
|
|
|
def duration(v):
|
|
"""§3.4: a duration in milliseconds, or Refuse."""
|
|
d = SPV.decimal_scaled(v, 3)
|
|
if d is None or d > MAX_INSTANT:
|
|
raise Refuse("import_time_malformed")
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------- §3.3 the XML subset
|
|
|
|
def doctype_end(b, at):
|
|
"""The end of a document type declaration of §3.3 at `at`, or None."""
|
|
def skip(p):
|
|
while p < len(b) and b[p] in b" \t\r\n":
|
|
p += 1
|
|
return p
|
|
|
|
def literal(p):
|
|
if p >= len(b) or b[p] not in b"\"'":
|
|
return None
|
|
q = b.find(b[p:p + 1], p + 1)
|
|
return None if q < 0 else q + 1
|
|
|
|
p = at + len(b"<!DOCTYPE")
|
|
a = skip(p)
|
|
if a == p:
|
|
return None
|
|
p = a
|
|
if p >= len(b) or not (chr(b[p]).isascii() and (chr(b[p]).isalpha() or b[p] == ord("_"))):
|
|
return None
|
|
p += 1
|
|
while p < len(b) and (chr(b[p]).isascii() and (chr(b[p]).isalnum() or chr(b[p]) in "_-.:")):
|
|
p += 1
|
|
a = skip(p)
|
|
if a > p:
|
|
if b.startswith(b"SYSTEM", a):
|
|
q = skip(a + 6)
|
|
if q == a + 6:
|
|
return None
|
|
p = literal(q)
|
|
elif b.startswith(b"PUBLIC", a):
|
|
q = skip(a + 6)
|
|
if q == a + 6:
|
|
return None
|
|
r = literal(q)
|
|
if r is None:
|
|
return None
|
|
s = skip(r)
|
|
if s == r:
|
|
return None
|
|
p = literal(s)
|
|
else:
|
|
p = a
|
|
if p is None:
|
|
return None
|
|
p = skip(p)
|
|
return p + 1 if p < len(b) and b[p] == ord(">") else None
|
|
|
|
|
|
def read_feed_xml(b):
|
|
"""§3.3: the root element, or None."""
|
|
b = bytearray(b)
|
|
p = 0
|
|
while True:
|
|
while p < len(b) and b[p] in b" \t\r\n":
|
|
p += 1
|
|
if b.startswith(b"<!--", p):
|
|
q = b.find(b"-->", p + 4)
|
|
if q < 0:
|
|
return None
|
|
p = q + 3
|
|
elif b.startswith(b"<?", p):
|
|
q = b.find(b"?>", p + 2)
|
|
if q < 0:
|
|
return None
|
|
p = q + 2
|
|
elif b.startswith(b"<!DOCTYPE", p) and "doctype" not in SKIP:
|
|
end = doctype_end(bytes(b), p)
|
|
if end is None:
|
|
return None
|
|
b[p:end] = b" " * (end - p)
|
|
break
|
|
else:
|
|
break
|
|
return SPV.read_xml(bytes(b))
|
|
|
|
|
|
def first(e, name):
|
|
k = SPV.kids(e, name)
|
|
return k[0] if k else None
|
|
|
|
|
|
def text_of(e):
|
|
return SPV.text(e) if e is not None else ""
|
|
|
|
|
|
# ---------------------------------------------------------------- §4 import
|
|
|
|
def check_inputs(inp):
|
|
"""§4.1."""
|
|
alone = inp["json"] is None and inp["xmltv"] is None and len(inp["mrss"]) > 0
|
|
if inp["json"] is not None:
|
|
sets = inp["xmltv"] is None and not inp["mrss"]
|
|
else:
|
|
sets = inp["xmltv"] is not None or bool(inp["mrss"])
|
|
owners = [m["owner"] for m in inp["mrss"]]
|
|
ok = (short_text(inp["channel"]) and sets
|
|
and (all(o is not None for o in owners) or all(o is None for o in owners))
|
|
and all(o is None or short_text(o) for o in owners)
|
|
and all(inp[k] is None or short_text(inp[k]) for k in ("owner", "id_system", "xmltv_channel"))
|
|
and short_text(inp["signer_id"])
|
|
and not (alone and inp["start_ms"] is None)
|
|
and (inp["start_ms"] is None or inp["start_ms"] <= MAX_INSTANT))
|
|
check("inputs", ok, "import_inputs_invalid")
|
|
|
|
|
|
def sized(b):
|
|
check("size", len(b) <= MAX_DOC, "import_oversized")
|
|
|
|
|
|
def description(e, desc_name, art, scheme_attr, scheme_default, rating_text):
|
|
"""§4.2 step 4, §4.3 step 3."""
|
|
title = text_of(first(e, "title"))
|
|
field(title != "" and long_text(title))
|
|
desc = text_of(first(e, desc_name))
|
|
field(long_text(desc))
|
|
cats = [SPV.text(c) for c in SPV.kids(e, "category")]
|
|
field(len(cats) <= 16 and all(short_text(c) for c in cats))
|
|
uris = [i["attrs"].get(art[1]) for i in SPV.kids(e, art[0])]
|
|
field(len(uris) <= 8 and all(u is not None and u != "" and long_text(u) for u in uris))
|
|
rating = ("", "")
|
|
r = first(e, "rating")
|
|
if r is not None:
|
|
system = r["attrs"].get(scheme_attr, scheme_default)
|
|
field(system == "" or short_text(system))
|
|
value = rating_text(r)
|
|
field(short_text(value))
|
|
rating = (system, value)
|
|
return {"title": title, "description": desc, "categories": cats, "rating": rating, "art": ("uris", uris),
|
|
"geo": (EVERYWHERE, []), "content_uri": "", "duration_ms": 0}
|
|
|
|
|
|
def read_xmltv(b, xch, id_system):
|
|
"""§4.2: (channel name or None, [(programme, description)])."""
|
|
sized(b)
|
|
root = read_feed_xml(b)
|
|
if root is None or root["name"] != "tv":
|
|
raise Refuse("import_xml_malformed")
|
|
progs = [p for p in SPV.kids(root, "programme") if p["attrs"].get("channel") == xch]
|
|
check("counts", len(progs) <= MAX_COUNT, "import_oversized")
|
|
name = None
|
|
for c in SPV.kids(root, "channel"):
|
|
if c["attrs"].get("id") == xch:
|
|
name = text_of(first(c, "display-name")) or None
|
|
break
|
|
if name is not None:
|
|
field(short_text(name))
|
|
if not progs:
|
|
raise Refuse("import_empty")
|
|
out = []
|
|
for p in progs:
|
|
if "start" not in p["attrs"]:
|
|
raise Refuse("import_time_malformed")
|
|
start = xmltv_time(p["attrs"]["start"])
|
|
if "stop" not in p["attrs"]:
|
|
check("no_stop", False, "import_no_stop")
|
|
stop = start + 1000
|
|
else:
|
|
stop = xmltv_time(p["attrs"]["stop"])
|
|
if stop <= start:
|
|
raise Refuse("import_time_malformed")
|
|
ids = [SPV.text(e) for e in SPV.kids(p, "episode-num") if e["attrs"].get("system", "onscreen") == id_system]
|
|
check("asset", bool(ids) and short_text(ids[0]), "import_asset_unknown")
|
|
asset = ids[0] if ids else "?"
|
|
d = description(p, "desc", ("icon", "src"), "system", "", lambda r: text_of(first(r, "value")))
|
|
out.append(({"start": start, "stop": stop, "asset": asset}, d))
|
|
return name, out
|
|
|
|
|
|
def restriction(item):
|
|
"""§4.3 step 4."""
|
|
r = None
|
|
for x in SPV.kids(item, "restriction"):
|
|
if x["attrs"].get("type") == "country":
|
|
r = x
|
|
break
|
|
if r is None:
|
|
return (EVERYWHERE, [])
|
|
rel = r["attrs"].get("relationship")
|
|
field(rel in ("allow", "deny"))
|
|
allow = rel == "allow"
|
|
toks = [t for t in SPV.text(r).replace("\t", " ").replace("\r", " ").replace("\n", " ").split(" ") if t]
|
|
if toks in (["all"], ["none"]):
|
|
return (EVERYWHERE, []) if (toks == ["all"]) == allow else (ONLY, [])
|
|
ok = bool(toks) and all(len(t) == 2 and t.isascii() and t.isalpha() for t in toks)
|
|
field(ok)
|
|
codes = sorted(set(t.upper() for t in toks))
|
|
return (ONLY if allow else EXCEPT, codes)
|
|
|
|
|
|
def read_mrss(b, owner, seen, count):
|
|
"""§4.3: (channel name or None, [item])."""
|
|
sized(b)
|
|
root = read_feed_xml(b)
|
|
ch = first(root, "channel") if root is not None and root["name"] == "rss" else None
|
|
if ch is None:
|
|
raise Refuse("import_xml_malformed")
|
|
items = SPV.kids(ch, "item")
|
|
count[0] += len(items)
|
|
check("counts", count[0] <= MAX_COUNT, "import_oversized")
|
|
name = text_of(first(ch, "title")) or None
|
|
if name is not None:
|
|
field(short_text(name))
|
|
out = []
|
|
for it in items:
|
|
g = first(it, "guid")
|
|
asset = SPV.text(g) if g is not None else ""
|
|
check("asset", short_text(asset), "import_asset_unknown")
|
|
check("duplicate", asset not in seen, "import_asset_duplicate")
|
|
seen.add(asset)
|
|
content = first(it, "content")
|
|
if content is None:
|
|
grp = first(it, "group")
|
|
content = first(grp, "content") if grp is not None else None
|
|
uri = content["attrs"].get("url", "") if content is not None else ""
|
|
field(long_text(uri))
|
|
dur = None
|
|
if content is not None and "duration" in content["attrs"]:
|
|
dur = duration(content["attrs"]["duration"])
|
|
if dur == 0:
|
|
raise Refuse("import_time_malformed")
|
|
d = description(it, "description", ("thumbnail", "url"), "scheme", "urn:simple", SPV.text)
|
|
d["geo"] = restriction(it)
|
|
d["content_uri"], d["duration_ms"] = uri, dur or 0
|
|
out.append({"asset": asset, "desc": d, "duration": dur, "owner": owner})
|
|
return name, out
|
|
|
|
|
|
def strict_json(b):
|
|
"""§4.4's reading of JSON: a value, or None (a member named twice, a number that is not an
|
|
integer of the form, an unpaired surrogate, more than 64 levels)."""
|
|
class Bad(Exception):
|
|
pass
|
|
|
|
def pairs(ps):
|
|
names = [n for n, _ in ps]
|
|
if len(set(names)) != len(names):
|
|
raise Bad()
|
|
return dict(ps)
|
|
|
|
def bad_number(s):
|
|
raise Bad()
|
|
|
|
def depth(v, d):
|
|
if d > 64:
|
|
raise Bad()
|
|
if isinstance(v, dict):
|
|
for k, x in v.items():
|
|
if not is_text_or_any(k):
|
|
raise Bad()
|
|
depth(x, d + 1)
|
|
elif isinstance(v, list):
|
|
for x in v:
|
|
depth(x, d + 1)
|
|
elif isinstance(v, str) and not is_text_or_any(v):
|
|
raise Bad()
|
|
|
|
def is_text_or_any(s):
|
|
return not any("\ud800" <= c <= "\udfff" for c in s)
|
|
|
|
try:
|
|
s = b.decode("utf-8")
|
|
v = json.loads(s, object_pairs_hook=pairs, parse_float=bad_number, parse_constant=bad_number)
|
|
depth(v, 1)
|
|
return v
|
|
except (UnicodeDecodeError, ValueError, Bad, RecursionError):
|
|
return None
|
|
|
|
|
|
def jint(v):
|
|
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= MAX_INSTANT
|
|
|
|
|
|
def exactly(o, names):
|
|
return isinstance(o, dict) and set(o) == set(names) and len(o) == len(names)
|
|
|
|
|
|
def json_structure(v):
|
|
"""§4.4's table: True when the form reads."""
|
|
if not exactly(v, ["kind", "channel_name", "programmes", "assets", "breaks"]) or v["kind"] != FEED_KIND:
|
|
return False
|
|
if not isinstance(v["channel_name"], str) or not all(isinstance(v[k], list) for k in ("programmes", "assets", "breaks")):
|
|
return False
|
|
for p in v["programmes"]:
|
|
if not (exactly(p, ["start_ms", "stop_ms", "asset_id"]) and jint(p["start_ms"]) and jint(p["stop_ms"])
|
|
and isinstance(p["asset_id"], str)):
|
|
return False
|
|
for a in v["assets"]:
|
|
if not exactly(a, ["asset_id", "title", "description", "content_uri", "categories", "rating", "artwork", "geo",
|
|
"rights_holder", "duration_ms"]):
|
|
return False
|
|
if not all(isinstance(a[k], str) for k in ("asset_id", "title", "description", "content_uri")):
|
|
return False
|
|
if not (isinstance(a["categories"], list) and all(isinstance(c, str) for c in a["categories"])):
|
|
return False
|
|
r = a["rating"]
|
|
if r is not None and not (exactly(r, ["system", "value"]) and isinstance(r["system"], str) and isinstance(r["value"], str)):
|
|
return False
|
|
if not isinstance(a["artwork"], list) or not all(exactly(w, ["uri", "sha256"]) and isinstance(w["uri"], str)
|
|
and isinstance(w["sha256"], str) for w in a["artwork"]):
|
|
return False
|
|
g = a["geo"]
|
|
if g is not None and not (isinstance(g, dict) and len(g) == 1 and list(g)[0] in ("allow", "deny")
|
|
and isinstance(list(g.values())[0], list)
|
|
and all(isinstance(c, str) for c in list(g.values())[0])):
|
|
return False
|
|
if not (a["rights_holder"] is None or isinstance(a["rights_holder"], str)) or not jint(a["duration_ms"]):
|
|
return False
|
|
for k in v["breaks"]:
|
|
if not (exactly(k, ["id", "start_ms", "duration_ms", "scte35"]) and isinstance(k["id"], str) and jint(k["start_ms"])
|
|
and jint(k["duration_ms"]) and (k["scte35"] is None or isinstance(k["scte35"], str))):
|
|
return False
|
|
return True
|
|
|
|
|
|
def hex_any(s):
|
|
if len(s) % 2 or any(c not in "0123456789abcdefABCDEF" for c in s):
|
|
return None
|
|
return bytes.fromhex(s)
|
|
|
|
|
|
def section_reads(b):
|
|
"""live-events §4.3's reading of form 1 (§4.5 step 3)."""
|
|
return len(b) >= 3 and b[0] == 0xFC and (((b[1] << 8) | b[2]) & 0x0FFF) + 3 == len(b)
|
|
|
|
|
|
def break_duration(d):
|
|
check("break_duration", 0 < d <= U32, "import_break_duration")
|
|
return d
|
|
|
|
|
|
def scte35(b):
|
|
check("scte35", b is not None and section_reads(b), "import_scte35_malformed")
|
|
return b
|
|
|
|
|
|
def read_json(b, nbreaks):
|
|
"""§4.4: (channel name, programmes, {asset id: (description, rights holder)}, breaks)."""
|
|
sized(b)
|
|
v = strict_json(b)
|
|
if v is None or not json_structure(v):
|
|
raise Refuse("import_json_malformed")
|
|
nbreaks[0] += len(v["breaks"])
|
|
check("counts", len(v["programmes"]) <= MAX_COUNT and len(v["assets"]) <= MAX_COUNT and nbreaks[0] <= MAX_COUNT,
|
|
"import_oversized")
|
|
field(short_text(v["channel_name"]))
|
|
progs = []
|
|
for p in v["programmes"]:
|
|
field(short_text(p["asset_id"]))
|
|
if p["stop_ms"] <= p["start_ms"]:
|
|
raise Refuse("import_time_malformed")
|
|
progs.append({"start": p["start_ms"], "stop": p["stop_ms"], "asset": p["asset_id"]})
|
|
assets = {}
|
|
for a in v["assets"]:
|
|
field(short_text(a["asset_id"]))
|
|
check("duplicate", a["asset_id"] not in assets, "import_asset_duplicate")
|
|
field(a["title"] != "" and long_text(a["title"]))
|
|
field(long_text(a["description"]) and long_text(a["content_uri"]))
|
|
field(len(a["categories"]) <= 16 and all(short_text(c) for c in a["categories"]))
|
|
rating = ("", "")
|
|
if a["rating"] is not None:
|
|
s, val = a["rating"]["system"], a["rating"]["value"]
|
|
field((s == "" or short_text(s)) and short_text(val))
|
|
rating = (s, val)
|
|
field(len(a["artwork"]) <= 8 and all(w["uri"] != "" and long_text(w["uri"]) and len(w["sha256"]) == 64
|
|
and all(c in "0123456789abcdef" for c in w["sha256"]) for w in a["artwork"]))
|
|
geo = (EVERYWHERE, [])
|
|
if a["geo"] is not None:
|
|
(rel, codes), = a["geo"].items()
|
|
ok = (all(len(c) == 2 and c.isascii() and c.isalpha() and c.isupper() for c in codes)
|
|
and all(codes[i] < codes[i + 1] for i in range(len(codes) - 1)) and (rel == "allow" or codes))
|
|
field(ok)
|
|
geo = (ONLY if rel == "allow" else EXCEPT, list(codes))
|
|
if a["rights_holder"] is not None:
|
|
field(short_text(a["rights_holder"]))
|
|
d = {"title": a["title"], "description": a["description"], "categories": a["categories"], "rating": rating,
|
|
"art": ("hashed", [(bytes.fromhex(w["sha256"]), w["uri"]) for w in a["artwork"]]), "geo": geo,
|
|
"content_uri": a["content_uri"], "duration_ms": a["duration_ms"]}
|
|
assets[a["asset_id"]] = (d, a["rights_holder"])
|
|
breaks = []
|
|
for k in v["breaks"]:
|
|
field(short_text(k["id"]))
|
|
sec = scte35(hex_any(k["scte35"])) if k["scte35"] is not None else None
|
|
breaks.append({"id": k["id"], "start": k["start_ms"], "duration": break_duration(k["duration_ms"]), "section": sec})
|
|
return v["channel_name"], progs, assets, breaks
|
|
|
|
|
|
def hex_sequence(v):
|
|
"""§4.5 step 3: an unquoted hexadecimal-sequence's bytes, or None."""
|
|
value, quoted = v
|
|
if quoted or value[:2] not in ("0x", "0X"):
|
|
return None
|
|
return hex_any(value[2:])
|
|
|
|
|
|
def read_playlist(b, nbreaks):
|
|
"""§4.5: the breaks."""
|
|
sized(b)
|
|
bad = Refuse("import_playlist_malformed")
|
|
try:
|
|
s = b.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
raise bad
|
|
lines = [ln[:-1] if ln.endswith("\r") else ln for ln in s.split("\n")]
|
|
if lines[0] != "#EXTM3U":
|
|
raise bad
|
|
groups = [] # [(id, {name: (value, quoted)})]
|
|
for ln in lines:
|
|
if not ln.startswith("#EXT-X-DATERANGE:"):
|
|
continue
|
|
a = SPV.attribute_list(ln[len("#EXT-X-DATERANGE:"):])
|
|
if a is None:
|
|
raise bad
|
|
d = {n: (v, q) for n, v, q in a}
|
|
if "ID" not in d or not d["ID"][1]:
|
|
raise bad
|
|
gid = d["ID"][0]
|
|
g = next((x for x in groups if x[0] == gid), None)
|
|
if g is None:
|
|
g = (gid, {})
|
|
groups.append(g)
|
|
for n, v in d.items():
|
|
if n in g[1] and g[1][n] != v and "conflict" not in SKIP:
|
|
raise bad
|
|
g[1].setdefault(n, v)
|
|
brks = [g for g in groups if "SCTE35-OUT" in g[1]]
|
|
nbreaks[0] += len(brks)
|
|
check("counts", nbreaks[0] <= MAX_COUNT, "import_oversized")
|
|
out = []
|
|
for gid, a in brks:
|
|
field(short_text(gid))
|
|
sd = a.get("START-DATE")
|
|
if sd is None or not sd[1]:
|
|
raise bad
|
|
start = hls_date_time(sd[0])
|
|
sec = scte35(hex_sequence(a["SCTE35-OUT"]))
|
|
if "SCTE35-IN" in a:
|
|
scte35(hex_sequence(a["SCTE35-IN"]))
|
|
|
|
def plain(v):
|
|
if v[1]:
|
|
raise bad
|
|
return duration(v[0])
|
|
if "DURATION" in a:
|
|
d = plain(a["DURATION"])
|
|
elif "END-DATE" in a:
|
|
if not a["END-DATE"][1]:
|
|
raise bad
|
|
end = hls_date_time(a["END-DATE"][0])
|
|
check("break_duration", end >= start, "import_break_duration")
|
|
d = end - start
|
|
elif "PLANNED-DURATION" in a:
|
|
d = plain(a["PLANNED-DURATION"])
|
|
else:
|
|
raise Refuse("import_break_duration")
|
|
out.append({"id": gid, "start": start, "duration": break_duration(d), "section": sec})
|
|
return out
|
|
|
|
|
|
def artwork_name(uri):
|
|
u = uri
|
|
for c in "?#":
|
|
u = u.split(c, 1)[0]
|
|
name = u.rsplit("/", 1)[-1]
|
|
ok = 1 <= len(name) <= 255 and name not in (".", "..") and all(c.isascii() and (c.isalnum() or c in "._-") for c in name)
|
|
return name if ok else None
|
|
|
|
|
|
def cue_id(bid, sec):
|
|
if sec is not None and len(sec) >= 18 and sec[4] & 0x80 == 0 and sec[13] == 0x05:
|
|
return int.from_bytes(sec[14:18], "big")
|
|
return int.from_bytes(sha(CUE_ID_DOMAIN, bid.encode())[:4], "big")
|
|
|
|
|
|
def cue_payload(k):
|
|
"""§4.8: live-events §4.3's cue, little-endian."""
|
|
out = bytes([4]) + struct.pack("<II", cue_id(k["id"], k["section"]), k["duration"])
|
|
if k["section"] is None:
|
|
return out + b"\x00"
|
|
return out + b"\x01" + struct.pack("<H", len(k["section"])) + k["section"]
|
|
|
|
|
|
def do_import(inp, files):
|
|
"""§4: {"entries", "window", "lineup" (its fields), "cues"}, or Refuse."""
|
|
check_inputs(inp)
|
|
xch = inp["xmltv_channel"] or inp["channel"]
|
|
id_system = inp["id_system"] or ID_SYSTEM
|
|
names, xmltv, items, seen, count, nbreaks, breaks, jf = [], None, [], set(), [0], [0], [], None
|
|
if inp["xmltv"] is not None:
|
|
n, xmltv = read_xmltv(files[inp["xmltv"]], xch, id_system)
|
|
names.append(n)
|
|
for k, m in enumerate(inp["mrss"]):
|
|
n, its = read_mrss(files[m["file"]], m["owner"], seen, count)
|
|
if k == 0:
|
|
names.append(n)
|
|
items += its
|
|
if inp["json"] is not None:
|
|
n, jp, ja, jb = read_json(files[inp["json"]], nbreaks)
|
|
names.append(n)
|
|
jf = (jp, ja)
|
|
breaks += jb
|
|
if inp["playlist"] is not None:
|
|
breaks += read_playlist(files[inp["playlist"]], nbreaks)
|
|
|
|
# §4.6 the programmes
|
|
if xmltv is not None:
|
|
progs = [p for p, _ in xmltv]
|
|
elif jf is not None:
|
|
if not jf[0]:
|
|
raise Refuse("import_empty")
|
|
progs = list(jf[0])
|
|
else:
|
|
if not items:
|
|
raise Refuse("import_empty")
|
|
at, progs = inp["start_ms"], []
|
|
for it in items:
|
|
check("duration_unknown", it["duration"] is not None, "import_duration_unknown")
|
|
stop = at + (it["duration"] or 1000)
|
|
if stop > MAX_INSTANT:
|
|
raise Refuse("import_time_malformed")
|
|
progs.append({"start": at, "stop": stop, "asset": it["asset"]})
|
|
at = stop
|
|
progs = sorted(progs, key=lambda p: p["start"])
|
|
for p, q in zip(progs, progs[1:]):
|
|
check("gap", p["stop"] >= q["start"], "import_gap")
|
|
check("overlap", p["stop"] <= q["start"], "import_overlap")
|
|
check("whole_seconds", all(p["start"] % 1000 == 0 for p in progs) and progs[-1]["stop"] % 1000 == 0,
|
|
"import_time_malformed")
|
|
|
|
# §4.6 their assets
|
|
item_of = {it["asset"]: it for it in items}
|
|
order = []
|
|
for p in progs:
|
|
if p["asset"] not in order:
|
|
order.append(p["asset"])
|
|
assets = []
|
|
for aid in order:
|
|
if jf is not None:
|
|
check("asset", aid in jf[1], "import_asset_unknown")
|
|
d, holder = jf[1].get(aid, (None, None))
|
|
if d is None:
|
|
continue
|
|
elif aid in item_of:
|
|
d, holder = item_of[aid]["desc"], item_of[aid]["owner"]
|
|
else:
|
|
d, holder = next(x for p, x in xmltv if p["asset"] == aid), None
|
|
holder = holder if holder is not None else inp["owner"]
|
|
check("owner", holder is not None, "import_owner_unknown")
|
|
kind, art = d["art"]
|
|
if kind == "uris":
|
|
hashed = []
|
|
for u in art:
|
|
n = artwork_name(u)
|
|
ok = n is not None and n in inp["artwork_files"]
|
|
check("artwork", ok, "import_artwork_missing")
|
|
body = inp["artwork_files"].get(n, b"") if n else b""
|
|
sized(body)
|
|
hashed.append((hashlib.sha256(body).digest(), u))
|
|
art = hashed
|
|
assets.append({"asset_id": aid, "title": d["title"], "description": d["description"], "categories": d["categories"],
|
|
"rating_system": d["rating"][0], "rating_value": d["rating"][1], "artwork": art, "geo": d["geo"][0],
|
|
"countries": d["geo"][1], "rights_holder": holder or "", "content_uri": d["content_uri"],
|
|
"duration_ms": d["duration_ms"]})
|
|
|
|
# §4.7 the schedule
|
|
breaks = sorted(breaks, key=lambda k: k["start"])
|
|
for k, n in zip(breaks, breaks[1:]):
|
|
check("break_overlap", k["start"] + k["duration"] <= n["start"], "import_overlap")
|
|
within = [[] for _ in progs]
|
|
for k in breaks:
|
|
at = next((i for i, p in enumerate(progs) if p["start"] <= k["start"] < p["stop"]), None)
|
|
ok = at is not None and k["start"] + k["duration"] <= progs[at]["stop"]
|
|
check("break_outside", ok, "import_break_outside")
|
|
if at is not None:
|
|
within[at].append(k)
|
|
entries = []
|
|
for p, ks in zip(progs, within):
|
|
at = p["start"]
|
|
for k in ks:
|
|
if k["start"] > at:
|
|
entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": k["start"] - at, "kind": "program"})
|
|
entries.append({"asset_id": k["id"], "start_ms": k["start"], "duration_ms": k["duration"], "kind": "ad"})
|
|
at = k["start"] + k["duration"]
|
|
if p["stop"] > at:
|
|
entries.append({"asset_id": p["asset"], "start_ms": at, "duration_ms": p["stop"] - at, "kind": "program"})
|
|
|
|
# §4.9
|
|
first_start, last_end = progs[0]["start"], progs[-1]["stop"]
|
|
ws, we = inp["window"] if inp["window"] is not None else (first_start, last_end)
|
|
check("window", ws < we and ws >= first_start and we <= last_end, "import_window_outside")
|
|
name = next((n for n in names if n is not None), inp["channel"])
|
|
lineup = {"channel_id": inp["channel"], "channel_name": name, "schedule_root": schedule_root(entries),
|
|
"schedule_start_ms": first_start, "schedule_end_ms": last_end, "programmes": [p["start"] for p in progs],
|
|
"assets": sorted(assets, key=lambda a: a["asset_id"].encode())}
|
|
cues = [{"break": k["id"], "start_ms": k["start"], "payload": cue_payload(k).hex()} for k in breaks]
|
|
return {"entries": entries, "window": (ws, we), "lineup": lineup, "cues": cues}
|
|
|
|
|
|
# ---------------------------------------------------------------- §5 the receipt
|
|
|
|
def schedule_root(entries):
|
|
"""revenue-share §4.2: the Merkle root of the entries' leaves."""
|
|
return RSV.merkle([RSV.leaf(e) for e in entries])
|
|
|
|
|
|
def aired_of(entries, ws, we):
|
|
return [i for i, e in enumerate(entries) if e["start_ms"] < we and e["start_ms"] + e["duration_ms"] > ws]
|
|
|
|
|
|
def u64(v):
|
|
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64
|
|
|
|
|
|
def hexn(v, n):
|
|
if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v):
|
|
return None
|
|
return bytes.fromhex(v)
|
|
|
|
|
|
def read_receipt(b):
|
|
"""§5: one JSON text of revenue-share §4.1's carriage, read as revenue-share's second
|
|
implementation reads it; the run, or None."""
|
|
try:
|
|
r = RSV.read_run(json.loads(b.decode("utf-8")))
|
|
RSV.run_payload(r)
|
|
return r
|
|
except (UnicodeError, ValueError, RSV.NoRead, KeyError, TypeError):
|
|
return None
|
|
|
|
|
|
def receipt_payload(r):
|
|
return RSV.run_payload(r)
|
|
|
|
|
|
def receipt_identity(r):
|
|
return RSV.run_identity(r)
|
|
|
|
|
|
def receipt_rules(r):
|
|
"""§5: revenue-share §4.3's rules 1 to 5 (rule 6, the signature, is §8.2 step 2's)."""
|
|
return "receipt_rules" in SKIP or RSV.run_verifies(r)
|
|
|
|
|
|
# ---------------------------------------------------------------- §6 the lineup
|
|
|
|
def lshort(s):
|
|
b = s.encode()
|
|
return bytes([len(b)]) + b
|
|
|
|
|
|
def llong(s):
|
|
b = s.encode()
|
|
return struct.pack("<H", len(b)) + b
|
|
|
|
|
|
def lineup_body(l, signer):
|
|
out = [MAGIC, bytes([VERSION]), lshort(l["channel_id"]), lshort(l["channel_name"]), l["schedule_root"],
|
|
struct.pack("<QQI", l["schedule_start_ms"], l["schedule_end_ms"], len(l["programmes"]))]
|
|
out += [struct.pack("<Q", p) for p in l["programmes"]]
|
|
out.append(struct.pack("<I", len(l["assets"])))
|
|
for a in l["assets"]:
|
|
out += [lshort(a["asset_id"]), llong(a["title"]), llong(a["description"]), bytes([len(a["categories"])])]
|
|
out += [lshort(c) for c in a["categories"]]
|
|
out += [lshort(a["rating_system"]), lshort(a["rating_value"]), bytes([len(a["artwork"])])]
|
|
out += [h + llong(u) for h, u in a["artwork"]]
|
|
out += [bytes([a["geo"], len(a["countries"])])] + [c.encode() for c in a["countries"]]
|
|
out += [lshort(a["rights_holder"]), llong(a["content_uri"]), struct.pack("<Q", a["duration_ms"])]
|
|
out.append(signer)
|
|
return b"".join(out)
|
|
|
|
|
|
class Cursor:
|
|
def __init__(self, b):
|
|
self.b, self.p = b, 0
|
|
|
|
def take(self, n):
|
|
if self.p + n > len(self.b):
|
|
raise ValueError("short")
|
|
out = self.b[self.p:self.p + n]
|
|
self.p += n
|
|
return out
|
|
|
|
def u(self, fmt):
|
|
return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0]
|
|
|
|
def short(self):
|
|
return self.take(self.u("<B")).decode("utf-8")
|
|
|
|
def long(self):
|
|
return self.take(self.u("<H")).decode("utf-8")
|
|
|
|
|
|
def read_lineup(b):
|
|
"""§6.4: the lineup, or None."""
|
|
try:
|
|
c = Cursor(b)
|
|
if c.take(4) != MAGIC or c.u("<B") != VERSION:
|
|
return None
|
|
l = {"channel_id": c.short(), "channel_name": c.short(), "schedule_root": c.take(32),
|
|
"schedule_start_ms": c.u("<Q"), "schedule_end_ms": c.u("<Q")}
|
|
s, e = l["schedule_start_ms"], l["schedule_end_ms"]
|
|
if not (short_text(l["channel_id"]) and short_text(l["channel_name"])):
|
|
return None
|
|
if not (s < e and s % 1000 == 0 and e % 1000 == 0 and e <= MAX_INSTANT):
|
|
return None
|
|
p = c.u("<I")
|
|
if not 1 <= p <= MAX_COUNT:
|
|
return None
|
|
l["programmes"] = [c.u("<Q") for _ in range(p)]
|
|
ps = l["programmes"]
|
|
if ps[0] != s or ps[-1] >= e or any(ps[i] >= ps[i + 1] for i in range(p - 1)) or any(x % 1000 for x in ps):
|
|
return None
|
|
a = c.u("<I")
|
|
if not 1 <= a <= MAX_COUNT:
|
|
return None
|
|
l["assets"] = []
|
|
for _ in range(a):
|
|
x = {"asset_id": c.short(), "title": c.long(), "description": c.long()}
|
|
n = c.u("<B")
|
|
if n > 16:
|
|
return None
|
|
x["categories"] = [c.short() for _ in range(n)]
|
|
x["rating_system"], x["rating_value"] = c.short(), c.short()
|
|
k = c.u("<B")
|
|
if k > 8:
|
|
return None
|
|
x["artwork"] = [(c.take(32), c.long()) for _ in range(k)]
|
|
x["geo"] = c.u("<B")
|
|
g = c.u("<B")
|
|
x["countries"] = [c.take(2).decode("ascii") for _ in range(g)]
|
|
x["rights_holder"], x["content_uri"], x["duration_ms"] = c.short(), c.long(), c.u("<Q")
|
|
cs = x["countries"]
|
|
ok = (short_text(x["asset_id"]) and x["title"] != "" and long_text(x["title"]) and long_text(x["description"])
|
|
and all(short_text(y) for y in x["categories"])
|
|
and (x["rating_system"] == "" or short_text(x["rating_system"]))
|
|
and (x["rating_value"] == "" or short_text(x["rating_value"]))
|
|
and not (x["rating_value"] == "" and x["rating_system"] != "")
|
|
and all(u != "" and long_text(u) for _, u in x["artwork"])
|
|
and x["geo"] <= 2 and not (x["geo"] == EVERYWHERE and cs) and not (x["geo"] == EXCEPT and not cs)
|
|
and all(len(y) == 2 and all("A" <= ch <= "Z" for ch in y) for y in cs)
|
|
and all(cs[i] < cs[i + 1] for i in range(len(cs) - 1))
|
|
and short_text(x["rights_holder"]) and long_text(x["content_uri"]) and x["duration_ms"] <= MAX_INSTANT)
|
|
if not ok or (l["assets"] and l["assets"][-1]["asset_id"].encode() >= x["asset_id"].encode()):
|
|
return None
|
|
l["assets"].append(x)
|
|
l["signer"], l["sig"] = c.take(32), c.take(64)
|
|
return l if c.p == len(b) else None
|
|
except (ValueError, UnicodeDecodeError, struct.error):
|
|
return None
|
|
|
|
|
|
def lineup_digest(l):
|
|
return sha(lineup_body(l, l["signer"]))
|
|
|
|
|
|
def spans(l):
|
|
ps = l["programmes"]
|
|
return [(s, ps[k + 1] if k + 1 < len(ps) else l["schedule_end_ms"]) for k, s in enumerate(ps)]
|
|
|
|
|
|
def programme_assets(l, sched):
|
|
"""§6.5 rule 4: each programme's asset, or None."""
|
|
out = []
|
|
for s, e in spans(l):
|
|
ids = [x["asset_id"] for x in sched if x["kind"] == "program" and s <= x["start_ms"] < e]
|
|
if not ids or any(i != ids[0] for i in ids):
|
|
return None
|
|
out.append(ids[0])
|
|
return out
|
|
|
|
|
|
def describes(l, r):
|
|
"""§6.5, each rule a check of its own."""
|
|
s = r["schedule"]
|
|
if not s:
|
|
return False
|
|
end = s[-1]["start_ms"] + s[-1]["duration_ms"]
|
|
if "describes_root" not in SKIP and (l["channel_id"] != r["channel_id"] or l["schedule_root"] != r["schedule_root"]
|
|
or l["schedule_start_ms"] != s[0]["start_ms"] or l["schedule_end_ms"] != end):
|
|
return False
|
|
starts = {x["start_ms"] for x in s}
|
|
if "describes_starts" not in SKIP and any(p not in starts for p in l["programmes"]):
|
|
return False
|
|
if "describes_assets" not in SKIP and programme_assets(l, s) is None:
|
|
return False
|
|
# Rule 5 over the programmes' assets: every program entry's asset in a programme's span.
|
|
held = {x["asset_id"] for a, b in spans(l) for x in s if x["kind"] == "program" and a <= x["start_ms"] < b}
|
|
return "describes_set" in SKIP or sorted(held, key=str.encode) == [a["asset_id"] for a in l["assets"]]
|
|
|
|
|
|
# ---------------------------------------------------------------- §7 the guide
|
|
|
|
def all_programmes(r, l):
|
|
ids = programme_assets(l, r["schedule"])
|
|
by = {a["asset_id"]: a for a in l["assets"]}
|
|
return [(s, e, by[i]) for (s, e), i in zip(spans(l), ids)]
|
|
|
|
|
|
def esc_text(s):
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def esc_attr(s):
|
|
return esc_text(s).replace('"', """).replace("\t", "	").replace("\n", " ")
|
|
|
|
|
|
def xmltv_guide(r, l, frm, to):
|
|
"""§7.2."""
|
|
ch = esc_attr(l["channel_id"])
|
|
o = ['<?xml version="1.0" encoding="UTF-8"?>\n',
|
|
'<tv source-data-url="%s%s:%s:%d:%d">\n' % (GUIDE_URN, r["schedule_root"].hex(), lineup_digest(l).hex(), frm, to),
|
|
' <channel id="%s">\n <display-name>%s</display-name>\n </channel>\n' % (ch, esc_text(l["channel_name"]))]
|
|
for s, e, a in all_programmes(r, l):
|
|
if not (s < to and e > frm):
|
|
continue
|
|
o.append(' <programme start="%s" stop="%s" channel="%s">\n' % (xmltv_format(s), xmltv_format(e), ch))
|
|
o.append(" <title>%s</title>\n" % esc_text(a["title"]))
|
|
if a["description"]:
|
|
o.append(" <desc>%s</desc>\n" % esc_text(a["description"]))
|
|
o += [" <category>%s</category>\n" % esc_text(c) for c in a["categories"]]
|
|
o += [' <icon src="%s"/>\n' % esc_attr(u) for _, u in a["artwork"]]
|
|
o.append(' <episode-num system="%s">%s</episode-num>\n' % (ID_SYSTEM, esc_text(a["asset_id"])))
|
|
if a["rating_value"]:
|
|
o.append(' <rating system="%s">\n' % esc_attr(a["rating_system"]) if a["rating_system"] else " <rating>\n")
|
|
o.append(" <value>%s</value>\n </rating>\n" % esc_text(a["rating_value"]))
|
|
o.append(" </programme>\n")
|
|
o.append("</tv>\n")
|
|
return "".join(o).encode("utf-8")
|
|
|
|
|
|
def now_and_next(progs, at):
|
|
for k, p in enumerate(progs):
|
|
if p[0] <= at < p[1]:
|
|
return p, progs[k + 1] if k + 1 < len(progs) else None
|
|
if progs and at < progs[0][0]:
|
|
return None, progs[0]
|
|
return None, None
|
|
|
|
|
|
def programme_doc(p):
|
|
s, e, a = p
|
|
geo = None
|
|
if a["geo"] == ONLY:
|
|
geo = {"allow": a["countries"]}
|
|
elif a["geo"] == EXCEPT:
|
|
geo = {"deny": a["countries"]}
|
|
return {"start_ms": s, "stop_ms": e, "asset_id": a["asset_id"], "title": a["title"], "description": a["description"],
|
|
"content_uri": a["content_uri"], "categories": a["categories"],
|
|
"rating": {"system": a["rating_system"], "value": a["rating_value"]} if a["rating_value"] else None,
|
|
"artwork": [{"sha256": h.hex(), "uri": u} for h, u in a["artwork"]], "geo": geo,
|
|
"rights_holder": a["rights_holder"], "duration_ms": a["duration_ms"]}
|
|
|
|
|
|
def jcs(v):
|
|
"""RFC 8785 over this document's values: sorted members (ASCII names), no space, strings as
|
|
JSON escapes them."""
|
|
return json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
def now_next_doc(r, l, at):
|
|
"""§7.3."""
|
|
now, nxt = now_and_next(all_programmes(r, l), at)
|
|
v = {"kind": NOW_NEXT_KIND, "channel": l["channel_id"], "channel_name": l["channel_name"],
|
|
"schedule_root": r["schedule_root"].hex(), "lineup": lineup_digest(l).hex(), "at_ms": at,
|
|
"now": programme_doc(now) if now else None, "next": programme_doc(nxt) if nxt else None}
|
|
return (jcs(v) + "\n").encode("utf-8")
|
|
|
|
|
|
def plain_uint(s):
|
|
return digits(s) and (s == "0" or s[0] != "0")
|
|
|
|
|
|
def read_guide(b):
|
|
"""§8.2 step 8: the guide as read, or None."""
|
|
if b[:1] == b"<":
|
|
root = read_feed_xml(b)
|
|
if root is None or root["name"] != "tv":
|
|
return None
|
|
url = root["attrs"].get("source-data-url", "")
|
|
if not url.startswith(GUIDE_URN):
|
|
return None
|
|
parts = url[len(GUIDE_URN):].split(":")
|
|
if len(parts) != 4 or not all(hexn(x, 32) is not None for x in parts[:2]) or not all(plain_uint(x) for x in parts[2:]):
|
|
return None
|
|
frm, to = int(parts[2]), int(parts[3])
|
|
if frm >= to:
|
|
return None
|
|
progs = []
|
|
for p in SPV.kids(root, "programme"):
|
|
try:
|
|
s, e = xmltv_time(p["attrs"].get("start", "")), xmltv_time(p["attrs"].get("stop", ""))
|
|
except Refuse:
|
|
return None
|
|
ids = [SPV.text(x) for x in SPV.kids(p, "episode-num") if x["attrs"].get("system", "onscreen") == ID_SYSTEM]
|
|
if not ids:
|
|
return None
|
|
progs.append((s, e, ids[0]))
|
|
return {"format": "xmltv", "root": parts[0], "lineup": parts[1], "from": frm, "to": to, "programmes": progs}
|
|
if b[:1] == b"{":
|
|
v = strict_json(b)
|
|
if not exactly(v, ["kind", "channel", "channel_name", "schedule_root", "lineup", "at_ms", "now", "next"]):
|
|
return None
|
|
if not jint(v["at_ms"]) or not isinstance(v["schedule_root"], str) or not isinstance(v["lineup"], str):
|
|
return None
|
|
out = []
|
|
for k in ("now", "next"):
|
|
p = v[k]
|
|
if p is None:
|
|
out.append(None)
|
|
elif isinstance(p, dict) and u64(p.get("start_ms")) and u64(p.get("stop_ms")) and isinstance(p.get("asset_id"), str):
|
|
out.append((p["start_ms"], p["stop_ms"], p["asset_id"]))
|
|
else:
|
|
return None
|
|
return {"format": "now-next", "root": v["schedule_root"], "lineup": v["lineup"], "at": v["at_ms"], "now": out[0],
|
|
"next": out[1]}
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- §8 verification
|
|
|
|
def verify(receipt, lineup, guide):
|
|
"""§8.2 and §8.3: the facts."""
|
|
f = {"verdict": "verified", "part": None, "receipt": None, "lineup": None, "guide": None, "entry": None}
|
|
|
|
def fail(part, reason):
|
|
f["verdict"], f["part"] = reason, part
|
|
return f
|
|
|
|
r = read_receipt(receipt)
|
|
if r is None:
|
|
return fail("receipt", "channel_receipt_malformed")
|
|
f["receipt"] = receipt_identity(r).hex()
|
|
bad = ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) if "receipt_sig" not in SKIP else None
|
|
if bad:
|
|
return fail("receipt", bad)
|
|
if not receipt_rules(r):
|
|
return fail("receipt", "channel_receipt_invalid")
|
|
l = read_lineup(lineup)
|
|
if l is None:
|
|
return fail("lineup", "lineup_malformed")
|
|
f["lineup"] = lineup_digest(l).hex()
|
|
bad = ED.verify(l["signer"], LINEUP_DOMAIN + lineup_body(l, l["signer"]), l["sig"]) if "lineup_sig" not in SKIP else None
|
|
if bad:
|
|
return fail("lineup", bad)
|
|
if l["signer"] != r["signer_pubkey"] and "signer" not in SKIP:
|
|
return fail("lineup", "lineup_signer_mismatch")
|
|
if not describes(l, r):
|
|
return fail("lineup", "lineup_schedule_mismatch")
|
|
if guide is None:
|
|
return f
|
|
g = read_guide(guide)
|
|
if g is None:
|
|
return fail("guide", "guide_malformed")
|
|
sched = r["schedule"]
|
|
|
|
def aired(s, e):
|
|
return any(sched[i]["start_ms"] < e and sched[i]["start_ms"] + sched[i]["duration_ms"] > s
|
|
for i in r["aired"] if i < len(sched))
|
|
|
|
progs = all_programmes(r, l)
|
|
if g["format"] == "xmltv":
|
|
shown = g["programmes"]
|
|
f["guide"] = {"format": "xmltv", "from_ms": g["from"], "to_ms": g["to"], "at_ms": None}
|
|
else:
|
|
shown = [p for p in (g["now"], g["next"]) if p is not None]
|
|
f["guide"] = {"format": "now-next", "from_ms": None, "to_ms": None, "at_ms": g["at"]}
|
|
f["guide"]["programmes"] = [{"start_ms": s, "stop_ms": e, "asset_id": i, "aired": aired(s, e)} for s, e, i in shown]
|
|
if (g["root"] != r["schedule_root"].hex() or g["lineup"] != f["lineup"]) and "source" not in SKIP:
|
|
return fail("guide", "guide_source_mismatch")
|
|
key = (lambda p: (p[0], p[1], p[2]["asset_id"]))
|
|
if g["format"] == "xmltv":
|
|
want = [key(p) for p in progs if p[0] < g["to"] and p[1] > g["from"]]
|
|
for i, p in enumerate(g["programmes"]):
|
|
if p not in want and "entries" not in SKIP:
|
|
f["entry"] = i
|
|
return fail("guide", "guide_entry_unscheduled")
|
|
expect = xmltv_guide(r, l, g["from"], g["to"])
|
|
else:
|
|
wn, wx = now_and_next(progs, g["at"])
|
|
for i, (got, w) in enumerate(((g["now"], wn), (g["next"], wx))):
|
|
if got != (key(w) if w else None) and "entries" not in SKIP:
|
|
f["entry"] = i
|
|
return fail("guide", "guide_entry_unscheduled")
|
|
expect = now_next_doc(r, l, g["at"])
|
|
if guide != expect and "bytes" not in SKIP:
|
|
return fail("guide", "guide_mismatch")
|
|
return f
|
|
|
|
|
|
# ---------------------------------------------------------------- the corpus
|
|
|
|
# ---------------------------------------------------------------- §7.4 the cue tags, §8.4
|
|
|
|
PL_VERSION = 3 # RFC 8216 §7
|
|
MAX_URI, MAX_SEGMENT, WINDOW_ENDS_BEFORE = 2048, U32, 253402300800000 # §7.4
|
|
|
|
|
|
def read_cues(b):
|
|
"""§8.4 step 4: the cues as `cues.json` holds them, or None."""
|
|
v = strict_json(b)
|
|
if not isinstance(v, list):
|
|
return None
|
|
out = []
|
|
for c in v:
|
|
if not isinstance(c, dict) or set(c) != {"break", "start_ms", "payload"}:
|
|
return None
|
|
if not isinstance(c["break"], str) or not jint(c["start_ms"]):
|
|
return None
|
|
p = c["payload"]
|
|
if not isinstance(p, str) or len(p) % 2 or any(ch not in "0123456789abcdef" for ch in p):
|
|
return None
|
|
out.append({"break": c["break"], "start_ms": c["start_ms"], "payload": bytes.fromhex(p)})
|
|
return out
|
|
|
|
|
|
def schedule_breaks(r):
|
|
"""§7.4: the receipt's ad entries of a duration above 0, in schedule order."""
|
|
return [(e["asset_id"], e["start_ms"], e["duration_ms"]) for e in r["schedule"] if e["kind"] == "ad" and e["duration_ms"] > 0]
|
|
|
|
|
|
def payload_section(p):
|
|
"""The section a cue's payload carries: (True, section) of form 1, (True, None) of form 0,
|
|
(False, None) when neither."""
|
|
if len(p) < 10:
|
|
return False, None
|
|
if p[9] == 0:
|
|
return True, None
|
|
if p[9] == 1 and len(p) >= 12:
|
|
n = p[10] | (p[11] << 8)
|
|
sec = p[12:12 + n]
|
|
if len(sec) == n and section_reads(sec):
|
|
return True, sec
|
|
return False, None
|
|
|
|
|
|
def bind_cues(r, cues):
|
|
"""§8.4 step 5: the breaks, each (id, start, duration, section); or Refuse with the index."""
|
|
breaks = schedule_breaks(r)
|
|
out = []
|
|
for i in range(max(len(breaks), len(cues))):
|
|
if i >= len(breaks) or i >= len(cues):
|
|
check("cues_count", False, ("cues_mismatch", i))
|
|
break
|
|
bid, start, dur = breaks[i]
|
|
c = cues[i]
|
|
check("cues_break", c["break"] == bid and c["start_ms"] == start, ("cues_mismatch", i))
|
|
ok, sec = payload_section(c["payload"])
|
|
check("cues_payload", ok and dur <= U32 and cue_payload({"id": bid, "duration": dur, "section": sec}) == c["payload"], ("cues_mismatch", i))
|
|
out.append({"id": bid, "start": start, "duration": dur, "end": start + dur, "section": sec})
|
|
return out
|
|
|
|
|
|
def window_holds(w):
|
|
"""§7.4's bounds, each a check of its own."""
|
|
segs = w["segments"]
|
|
end = w["start"] + sum(d for _, d in segs)
|
|
bounds = {
|
|
"pl_segments": len(segs) > 0,
|
|
"pl_uri_empty": all(len(u) > 0 for u, _ in segs),
|
|
"pl_uri_long": all(len(u.encode()) <= MAX_URI for u, _ in segs),
|
|
"pl_uri_chars": all(0x21 <= ord(c) <= 0x7E for u, _ in segs for c in u),
|
|
"pl_uri_hash": not any(u.startswith("#") for u, _ in segs),
|
|
"pl_duration_zero": all(d >= 1 for _, d in segs),
|
|
"pl_duration_long": all(d <= MAX_SEGMENT for _, d in segs),
|
|
"pl_sequence": w["sequence"] <= MAX_INSTANT,
|
|
"pl_end": end < WINDOW_ENDS_BEFORE,
|
|
}
|
|
return all(ok for name, ok in bounds.items() if name not in SKIP)
|
|
|
|
|
|
def seg_spans(w):
|
|
out, at = [], w["start"]
|
|
for _, d in w["segments"]:
|
|
out.append((at, at + d))
|
|
at += d
|
|
return out
|
|
|
|
|
|
def first_in(b, sp):
|
|
"""The first segment that puts a break in the window (§7.4), or None."""
|
|
for k, (s, e) in enumerate(sp):
|
|
if e == b["start"] or b["start"] <= s < b["end"] or s == b["end"]:
|
|
return k
|
|
return None
|
|
|
|
|
|
def pl_span(ms):
|
|
return "%d.%03d" % (ms // 1000, ms % 1000)
|
|
|
|
|
|
def pl_date(ms):
|
|
d = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=ms)
|
|
return d.strftime("%Y-%m-%dT%H:%M:%S") + ".%03dZ" % (ms % 1000)
|
|
|
|
|
|
def render(breaks, w):
|
|
"""§7.4: the playlist of a window, or Refuse((reason, index))."""
|
|
check("pl_window", window_holds(w), ("playlist_malformed", None))
|
|
sp = seg_spans(w)
|
|
for k, (s, e) in enumerate(sp):
|
|
check("aligned", not any(s < x < e for b in breaks for x in (b["start"], b["end"])), ("cue_unaligned", k))
|
|
for i, b in enumerate(breaks):
|
|
if first_in(b, sp) is not None and b["section"] is not None:
|
|
for name, c in (("writable_quote", '"'), ("writable_line_feed", "\n"), ("writable_carriage_return", "\r")):
|
|
check(name, c not in b["id"], ("cue_unwritable", i))
|
|
target = max((d + 999) // 1000 for _, d in w["segments"])
|
|
out = ["#EXTM3U", "#EXT-X-VERSION:%d" % PL_VERSION, "#EXT-X-TARGETDURATION:%d" % target,
|
|
"#EXT-X-MEDIA-SEQUENCE:%d" % w["sequence"], "#EXT-X-PROGRAM-DATE-TIME:" + pl_date(w["start"])]
|
|
for k, ((uri, d), (s, _)) in enumerate(zip(w["segments"], sp)):
|
|
for b in breaks:
|
|
if b["section"] is None:
|
|
continue
|
|
rid = "%s@%d" % (b["id"], b["start"])
|
|
if first_in(b, sp) == k:
|
|
out.append('#EXT-X-DATERANGE:ID="%s",START-DATE="%s",PLANNED-DURATION=%s,SCTE35-OUT=0x%s'
|
|
% (rid, pl_date(b["start"]), pl_span(b["duration"]), b["section"].hex().upper()))
|
|
if s == b["end"]:
|
|
out.append('#EXT-X-DATERANGE:ID="%s",START-DATE="%s",DURATION=%s' % (rid, pl_date(b["start"]), pl_span(b["duration"])))
|
|
if any(b["end"] == s for b in breaks):
|
|
out.append("#EXT-X-CUE-IN")
|
|
starting = [b for b in breaks if b["start"] == s]
|
|
inside = [b for b in breaks if b["start"] < s < b["end"]]
|
|
if starting:
|
|
out.append("#EXT-X-CUE-OUT:" + pl_span(starting[0]["duration"]))
|
|
elif inside:
|
|
b = inside[0]
|
|
out.append("#EXT-X-CUE-OUT-CONT:ElapsedTime=%s,Duration=%s" % (pl_span(s - b["start"]), pl_span(b["duration"])))
|
|
out += ["#EXTINF:%s," % pl_span(d), uri]
|
|
if w["ended"]:
|
|
out.append("#EXT-X-ENDLIST")
|
|
return "".join(ln + "\n" for ln in out)
|
|
|
|
|
|
def read_cue_playlist(b):
|
|
"""§8.4 step 6: a playlist's window, or None."""
|
|
try:
|
|
t = b.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return None
|
|
if not t.endswith("\n"):
|
|
return None
|
|
lines = t[:-1].split("\n")
|
|
if lines[0] != "#EXTM3U":
|
|
return None
|
|
seq, first, ended, segs, i = None, None, False, [], 1
|
|
try:
|
|
while i < len(lines):
|
|
ln = lines[i]
|
|
if ln.startswith("#EXT-X-MEDIA-SEQUENCE:"):
|
|
v = ln[len("#EXT-X-MEDIA-SEQUENCE:"):]
|
|
if seq is not None or not plain_uint(v) or int(v) > MAX_INSTANT:
|
|
return None
|
|
seq = int(v)
|
|
elif ln.startswith("#EXT-X-PROGRAM-DATE-TIME:"):
|
|
if first is None and not segs:
|
|
first = hls_date_time(ln[len("#EXT-X-PROGRAM-DATE-TIME:"):])
|
|
elif ln == "#EXT-X-ENDLIST":
|
|
ended = True
|
|
elif ln.startswith("#EXTINF:"):
|
|
v = ln[len("#EXTINF:"):]
|
|
if not v.endswith(",") or i + 1 >= len(lines):
|
|
return None
|
|
segs.append((lines[i + 1], duration(v[:-1])))
|
|
i += 1
|
|
i += 1
|
|
except Refuse:
|
|
return None
|
|
if seq is None or first is None:
|
|
return None
|
|
w = {"sequence": seq, "start": first, "segments": segs, "ended": ended}
|
|
return w if window_holds(w) else None
|
|
|
|
|
|
def verify_playlist(receipt, cues, playlist):
|
|
"""§8.4: the facts."""
|
|
f = {"verdict": "verified", "part": None, "receipt": None, "window": None, "index": None}
|
|
|
|
def fail(part, reason, index=None):
|
|
f["verdict"], f["part"], f["index"] = reason, part, index
|
|
return f
|
|
|
|
r = read_receipt(receipt)
|
|
if r is None:
|
|
return fail("receipt", "channel_receipt_malformed")
|
|
f["receipt"] = receipt_identity(r).hex()
|
|
bad = ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) if "receipt_sig" not in SKIP else None
|
|
if bad:
|
|
return fail("receipt", bad)
|
|
if not receipt_rules(r):
|
|
return fail("receipt", "channel_receipt_invalid")
|
|
c = read_cues(cues)
|
|
if c is None:
|
|
return fail("cues", "cues_malformed")
|
|
try:
|
|
breaks = bind_cues(r, c)
|
|
except Refuse as e:
|
|
return fail("cues", e.reason[0], e.reason[1])
|
|
w = read_cue_playlist(playlist)
|
|
if w is None:
|
|
return fail("playlist", "playlist_malformed")
|
|
sp = seg_spans(w)
|
|
f["window"] = {"media_sequence": w["sequence"], "start_ms": w["start"], "segments": len(w["segments"]), "ended": w["ended"],
|
|
"breaks": [{"break": b["id"], "start_ms": b["start"], "duration_ms": b["duration"], "scte35": b["section"] is not None}
|
|
for b in breaks if first_in(b, sp) is not None]}
|
|
try:
|
|
text = render(breaks, w)
|
|
except Refuse as e:
|
|
return fail("playlist", e.reason[0], e.reason[1])
|
|
if "pl_bytes" not in SKIP and text.encode("utf-8") != playlist:
|
|
return fail("playlist", "playlist_mismatch")
|
|
return f
|
|
|
|
|
|
def window_of(v):
|
|
return {"sequence": v["media_sequence"], "start": v["start_ms"], "segments": [(s["uri"], s["duration_ms"]) for s in v["segments"]],
|
|
"ended": v["ended"]}
|
|
|
|
|
|
def check_playlist(v, files):
|
|
"""None when a window renders what the corpus states, else why not."""
|
|
r = read_receipt(files[v["receipt"]])
|
|
c = read_cues(files[v["cues"]])
|
|
if r is None or c is None:
|
|
return "the receipt or the cues do not read"
|
|
o = v["outcome"]
|
|
try:
|
|
text = render(bind_cues(r, c), window_of(v["window"]))
|
|
except Refuse as e:
|
|
got = {"refused": e.reason[0], "index": e.reason[1]}
|
|
return None if o == got else "refused %s, the corpus states %s" % (got, o)
|
|
if "playlist" not in o or files.get(o["playlist"]) != text.encode("utf-8"):
|
|
return "renders another playlist than the corpus's %s" % o
|
|
return None
|
|
|
|
|
|
def check_playlist_verification(v, files):
|
|
files = apply_edits(files, v["edits"])
|
|
facts = verify_playlist(files[v["receipt"]], files[v["cues"]], files[v["playlist"]])
|
|
return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"])
|
|
|
|
|
|
def apply_edits(files, edits):
|
|
out = dict(files)
|
|
for e in edits:
|
|
b = out[e["file"]]
|
|
if "pad" in e:
|
|
out[e["file"]] = b + b" " * e["pad"]
|
|
continue
|
|
s = b.decode("utf-8")
|
|
if s.count(e["find"]) != 1:
|
|
raise ValueError("%s: an edit's find does not occur once" % e["file"])
|
|
with_ = e["find"] + e["replace"] * e["repeat"] if "repeat" in e else e["replace"]
|
|
out[e["file"]] = s.replace(e["find"], with_, 1).encode("utf-8")
|
|
return out
|
|
|
|
|
|
def import_inputs(v, files):
|
|
inp = dict(v["inputs"])
|
|
inp["artwork_files"] = {n: files[n] for n in inp["artwork"]}
|
|
if inp["window"] is not None:
|
|
inp["window"] = tuple(inp["window"])
|
|
return inp
|
|
|
|
|
|
def check_import(doc, v, files, op_key):
|
|
"""None when the import gets the outcome the corpus states, else why not."""
|
|
files = apply_edits(files, v["edits"])
|
|
o = v["outcome"]
|
|
try:
|
|
got = do_import(import_inputs(v, files), files)
|
|
except Refuse as e:
|
|
return None if o.get("refused") == e.reason else "refused %s, the corpus states %s" % (e.reason, o)
|
|
if "refused" in o:
|
|
return "imported, the corpus states %s" % o
|
|
r = read_receipt(files[o["receipt"]])
|
|
if r is None:
|
|
return "the receipt does not read"
|
|
inp = v["inputs"]
|
|
want_grant = {"grant_hash": bytes(32), "capability": "video.channel", "joule_ceiling_micro": U64, "funds_ceiling": None}
|
|
ws, we = got["window"]
|
|
same = (r["channel_id"] == inp["channel"] and r["schedule"] == got["entries"] and r["schedule_root"] == schedule_root(got["entries"])
|
|
and (r["window_start_ms"], r["window_end_ms"]) == (ws, we) and r["aired"] == aired_of(got["entries"], ws, we)
|
|
and r["work_schedule_ops"] == len(got["entries"]) and r["joules_micro"] == 0 and r["class"] is None
|
|
and r["grant"] == want_grant and r["parent"] == (bytes.fromhex(inp["parent_receipt_hash"]) if inp["parent_receipt_hash"] else None)
|
|
and r["signer_pubkey"] == op_key and r["signer_id"] == inp["signer_id"])
|
|
if not same:
|
|
return "the receipt is not the import's"
|
|
if ED.verify(r["signer_pubkey"], receipt_payload(r), r["sig"]) or receipt_identity(r).hex() != o["receipt_hash"]:
|
|
return "the receipt's signature or identity"
|
|
lb = bytes.fromhex(o["lineup"])
|
|
body = lineup_body(got["lineup"], op_key)
|
|
if lb[:-64] != body or ED.verify(op_key, LINEUP_DOMAIN + body, lb[-64:]) or sha(body).hex() != o["lineup_digest"]:
|
|
return "the lineup is not the import's, or its signature does not verify"
|
|
if got["cues"] != o["cues"]:
|
|
return "the cues differ"
|
|
return None
|
|
|
|
|
|
def check_verification(v, files):
|
|
files = apply_edits(files, v["edits"])
|
|
g = files[v["guide"]] if v["guide"] else None
|
|
facts = verify(files[v["receipt"]], bytes.fromhex(v["lineup"]), g)
|
|
return None if facts == v["facts"] else "states %s, the corpus %s" % (facts, v["facts"])
|
|
|
|
|
|
def corpus_files(doc):
|
|
names = {"vectors.json", "README.md"}
|
|
for v in doc["imports"]:
|
|
i = v["inputs"]
|
|
names |= {x for x in (i["xmltv"], i["json"], i["playlist"]) if x} | {m["file"] for m in i["mrss"]} | set(i["artwork"])
|
|
if "receipt" in v["outcome"]:
|
|
names.add(v["outcome"]["receipt"])
|
|
for v in doc["verifications"]:
|
|
names |= {x for x in (v["receipt"], v["guide"]) if x}
|
|
for v in doc["playlists"]:
|
|
names |= {v["receipt"], v["cues"]} | ({v["outcome"]["playlist"]} if "playlist" in v["outcome"] else set())
|
|
for v in doc["playlist_verifications"]:
|
|
names |= {v["receipt"], v["cues"], v["playlist"]}
|
|
return names
|
|
|
|
|
|
def check_all(doc, files, listing, quiet=False):
|
|
"""The failures of every vector and of the corpus as a whole."""
|
|
fails = []
|
|
op_key = bytes.fromhex(doc["operator"]["key"])
|
|
for v in doc["imports"]:
|
|
try:
|
|
why = check_import(doc, v, files, op_key)
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
why = "broken: %r" % (e,)
|
|
if why:
|
|
fails.append("import %s: %s" % (v["name"], why))
|
|
for v in doc["verifications"]:
|
|
try:
|
|
why = check_verification(v, files)
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
why = "broken: %r" % (e,)
|
|
if why:
|
|
fails.append("verification %s: %s" % (v["name"], why))
|
|
for v in doc["playlists"]:
|
|
try:
|
|
why = check_playlist(v, files)
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
why = "broken: %r" % (e,)
|
|
if why:
|
|
fails.append("playlist %s: %s" % (v["name"], why))
|
|
for v in doc["playlist_verifications"]:
|
|
try:
|
|
why = check_playlist_verification(v, files)
|
|
except (KeyError, ValueError, TypeError) as e:
|
|
why = "broken: %r" % (e,)
|
|
if why:
|
|
fails.append("playlist verification %s: %s" % (v["name"], why))
|
|
added, reused = reasons()
|
|
seen = {v["outcome"]["refused"] for v in doc["imports"] if "refused" in v["outcome"]}
|
|
seen |= {v["facts"]["verdict"] for v in doc["verifications"]}
|
|
seen |= {v["outcome"]["refused"] for v in doc["playlists"] if "refused" in v["outcome"]}
|
|
seen |= {v["facts"]["verdict"] for v in doc["playlist_verifications"]}
|
|
for r in added + reused:
|
|
if r not in seen:
|
|
fails.append("the corpus gives no %s" % r)
|
|
for r in seen - set(added) - set(reused) - {"verified"}:
|
|
fails.append("%s is not a reason of §10" % r)
|
|
if listing != corpus_files(doc):
|
|
fails.append("the files are not the corpus's: %s" % sorted(listing ^ corpus_files(doc)))
|
|
if not quiet:
|
|
for f in fails:
|
|
print("FAIL", f)
|
|
return fails
|
|
|
|
|
|
def load(d):
|
|
listing = set(os.listdir(d))
|
|
files = {}
|
|
for n in listing:
|
|
with open(os.path.join(d, n), "rb") as f:
|
|
files[n] = f.read()
|
|
return json.loads(files["vectors.json"]), files, listing
|
|
|
|
|
|
def flip(h, at):
|
|
c = "0" if h[at] != "0" else "1"
|
|
return h[:at] + c + h[at + 1:]
|
|
|
|
|
|
def mutations(doc, files, listing):
|
|
"""Copies of the corpus with one defect each: every one must fail."""
|
|
out = []
|
|
|
|
def m(name, f):
|
|
d, fs, ls = copy.deepcopy(doc), dict(files), set(listing)
|
|
f(d, fs, ls)
|
|
out.append((name, d, fs, ls))
|
|
|
|
imp = lambda d, n: next(v for v in d["imports"] if v["name"] == n) # noqa: E731
|
|
ver = lambda d, n: next(v for v in d["verifications"] if v["name"] == n) # noqa: E731
|
|
m("a lineup byte of the sample's outcome", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("lineup", flip(imp(d, "sample")["outcome"]["lineup"], 120)))
|
|
m("the sample's receipt hash", lambda d, fs, ls: imp(d, "sample")["outcome"].__setitem__("receipt_hash", flip(imp(d, "sample")["outcome"]["receipt_hash"], 0)))
|
|
m("a cue's payload", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][0].__setitem__("payload", flip(imp(d, "sample")["outcome"]["cues"][0]["payload"], 4)))
|
|
m("a cue's start", lambda d, fs, ls: imp(d, "sample")["outcome"]["cues"][1].__setitem__("start_ms", 1))
|
|
m("a refusal's code", lambda d, fs, ls: imp(d, "gap")["outcome"].__setitem__("refused", "import_overlap"))
|
|
m("an edit dropped", lambda d, fs, ls: imp(d, "no_stop").__setitem__("edits", []))
|
|
m("a verdict", lambda d, fs, ls: ver(d, "guide_mismatch")["facts"].__setitem__("verdict", "verified"))
|
|
m("an aired flag", lambda d, fs, ls: ver(d, "xmltv_guide")["facts"]["guide"]["programmes"][2].__setitem__("aired", True))
|
|
m("the unscheduled entry's index", lambda d, fs, ls: ver(d, "guide_entry_unscheduled")["facts"].__setitem__("entry", 1))
|
|
m("a guide's title", lambda d, fs, ls: fs.__setitem__("sample.guide.xml", fs["sample.guide.xml"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens")))
|
|
m("a now/next title", lambda d, fs, ls: fs.__setitem__("sample.now-next.json", fs["sample.now-next.json"].replace(b"Tidepool Kitchen", b"Tidepool Kitchens")))
|
|
m("an artwork file", lambda d, fs, ls: fs.__setitem__("art-tk-7.svg", fs["art-tk-7.svg"] + b" "))
|
|
m("a catalogue's duration", lambda d, fs, ls: fs.__setitem__("sample-a.mrss.xml", fs["sample-a.mrss.xml"].replace(b'duration="1620"/>', b'duration="1621"/>', 1)))
|
|
m("a break's start", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"18:45:30.500Z", b"18:45:31.500Z")))
|
|
m("an SCTE-35 section", lambda d, fs, ls: fs.__setitem__("sample.m3u8", fs["sample.m3u8"].replace(b"03E97FEFFE", b"03EA7FEFFE", 1)))
|
|
m("the receipt's window", lambda d, fs, ls: fs.__setitem__("sample.channel.json", fs["sample.channel.json"].replace(b'"window_end_ms": 1793300400000', b'"window_end_ms": 1793300401000')))
|
|
m("a file added", lambda d, fs, ls: (fs.__setitem__("extra.txt", b"x"), ls.add("extra.txt")))
|
|
m("a vector of a reason dropped", lambda d, fs, ls: d.__setitem__("imports", [v for v in d["imports"] if v["name"] != "window_outside"]))
|
|
pl = lambda d, n: next(v for v in d["playlists"] if v["name"] == n) # noqa: E731
|
|
plv = lambda d, n: next(v for v in d["playlist_verifications"] if v["name"] == n) # noqa: E731
|
|
m("a playlist's EXT-X-CUE-OUT duration", lambda d, fs, ls: fs.__setitem__("sample.out-in.m3u8", fs["sample.out-in.m3u8"].replace(b"CUE-OUT:90.000", b"CUE-OUT:90.001")))
|
|
m("a playlist's elapsed time", lambda d, fs, ls: fs.__setitem__("sample.inside.m3u8", fs["sample.inside.m3u8"].replace(b"ElapsedTime=40.000", b"ElapsedTime=41.000")))
|
|
m("a date range's section", lambda d, fs, ls: fs.__setitem__("sample.offset.m3u8", fs["sample.offset.m3u8"].replace(b"SCTE35-OUT=0xFC30", b"SCTE35-OUT=0xFC31")))
|
|
m("the ended window's end", lambda d, fs, ls: fs.__setitem__("sample.offset.m3u8", fs["sample.offset.m3u8"].replace(b"#EXT-X-ENDLIST\n", b"")))
|
|
m("a window's first segment", lambda d, fs, ls: pl(d, "out_in")["window"].__setitem__("start_ms", pl(d, "out_in")["window"]["start_ms"] + 10000))
|
|
m("a refusal's index", lambda d, fs, ls: pl(d, "unaligned_end")["outcome"].__setitem__("index", 9))
|
|
m("a cues file's payload", lambda d, fs, ls: fs.__setitem__("no-section.cues.json", fs["no-section.cues.json"].replace(b'"payload": "04', b'"payload": "03', 1)))
|
|
m("a playlist verification's break", lambda d, fs, ls: plv(d, "playlist_inside")["facts"]["window"]["breaks"][0].__setitem__("scte35", False))
|
|
m("a playlist verification's index", lambda d, fs, ls: plv(d, "cues_missing")["facts"].__setitem__("index", 2))
|
|
m("a target duration rounded down", lambda d, fs, ls: fs.__setitem__("sample.fractional.m3u8", fs["sample.fractional.m3u8"].replace(b"TARGETDURATION:7\n", b"TARGETDURATION:6\n")))
|
|
m("a span's milliseconds dropped", lambda d, fs, ls: fs.__setitem__("sample.fractional.m3u8", fs["sample.fractional.m3u8"].replace(b"ElapsedTime=6.006,", b"ElapsedTime=6.000,")))
|
|
m("the last instant's window a millisecond later", lambda d, fs, ls: pl(d, "last_instant")["window"].__setitem__("start_ms", pl(d, "last_instant")["window"]["start_ms"] + 1))
|
|
m("a refused URI's bound", lambda d, fs, ls: pl(d, "uri_too_long")["window"]["segments"][0].__setitem__("uri", pl(d, "uri_too_long")["window"]["segments"][0]["uri"][1:]))
|
|
return out
|
|
|
|
|
|
def main(corpus_dir):
|
|
global SKIP
|
|
doc, files, listing = load(corpus_dir)
|
|
if check_all(doc, files, listing):
|
|
return 1
|
|
print("channel lineup corpus: %d imports, %d verifications, %d playlists and %d playlist verifications hold"
|
|
% (len(doc["imports"]), len(doc["verifications"]), len(doc["playlists"]), len(doc["playlist_verifications"])))
|
|
bad = 0
|
|
for name, d, fs, ls in mutations(doc, files, listing):
|
|
if not check_all(d, fs, ls, quiet=True):
|
|
print("FAIL mutation not caught:", name)
|
|
bad += 1
|
|
print("mutations: %d copies, %d not caught" % (len(mutations(doc, files, listing)), bad))
|
|
checks = ["inputs", "size", "fields", "timezone", "no_stop", "asset", "duplicate", "counts", "doctype", "conflict",
|
|
"scte35", "break_duration", "duration_unknown", "gap", "overlap", "whole_seconds", "owner", "artwork",
|
|
"break_overlap", "break_outside", "window", "receipt_sig", "receipt_rules", "lineup_sig", "signer",
|
|
"describes_root", "describes_starts", "describes_assets", "describes_set", "source", "entries", "bytes",
|
|
"cues_count", "cues_break", "cues_payload", "pl_window", "pl_segments", "pl_uri_empty", "pl_uri_long",
|
|
"pl_uri_chars", "pl_uri_hash", "pl_duration_zero", "pl_duration_long", "pl_sequence", "pl_end", "aligned",
|
|
"writable_quote", "writable_line_feed", "writable_carriage_return", "pl_bytes"]
|
|
weak = 0
|
|
for c in checks:
|
|
SKIP = {c}
|
|
try:
|
|
caught = bool(check_all(doc, files, listing, quiet=True))
|
|
except Exception:
|
|
caught = True
|
|
if not caught:
|
|
print("FAIL weakened verifier not caught: without", c)
|
|
weak += 1
|
|
SKIP = set()
|
|
print("weakened verifiers: %d checks dropped one at a time, %d not caught" % (len(checks), weak))
|
|
return 1 if bad or weak else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 2:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
sys.exit(main(sys.argv[1]))
|