wai: a channel's breaks marked in its HLS Media Playlist, and why a delivery took the route it took #636
41 changed files with 7837 additions and 102 deletions
|
|
@ -558,6 +558,7 @@ and a role are fixed once registered.
|
|||
| `wai-robustness-claim` | document-kind | defined | robustness-evidence §8 |
|
||||
| `wai-robustness-corpus` | document-kind | defined | robustness-evidence §3 |
|
||||
| `wai-robustness-harness:1` | scheme | defined | robustness-evidence §6 |
|
||||
| `wai-route-delivery-decision` | document-kind | defined | route-record §6.3 |
|
||||
| `wai-route-policy` | document-kind | defined | route-record §3.3 |
|
||||
| `wai-route-pricebook` | document-kind | defined | route-record §3.2 |
|
||||
| `wai-route-record` | document-kind | defined | route-record §5.1 |
|
||||
|
|
|
|||
12
wai/SPEC.md
12
wai/SPEC.md
|
|
@ -1338,7 +1338,11 @@ its reason, a gap, an overlap, a programme with no stop, a time zone named by le
|
|||
outside its programme and a window outside the schedule. The guide, an XMLTV document or a
|
||||
now/next document for a player's guide row, is rendered from the receipt and the lineup and names
|
||||
both, so a verifier recomputes it and compares, and states of each of its programmes whether the
|
||||
receipt's program-as-run says it aired:
|
||||
receipt's program-as-run says it aired. A Media Playlist of the channel marks each break of the
|
||||
receipt's schedule where it places it, with `EXT-X-CUE-OUT` and its duration, `EXT-X-CUE-OUT-CONT`
|
||||
and `EXT-X-CUE-IN`, and with RFC 8216's date ranges carrying its SCTE-35 section, so the playlist
|
||||
of the channel's segments, MPEG-2 transport stream segments among them, carries the schedule's ad
|
||||
markers; a verifier given the receipt, the cues and the playlist recomputes every tag and compares:
|
||||
[`extensions/channel-lineup.md`](extensions/channel-lineup.md).
|
||||
|
||||
And for **watching together** in a room — `wai.live.cowatch`: people in different places
|
||||
|
|
@ -1619,7 +1623,11 @@ and of every attempt, each with its hop's OER/2 job receipt and whether it was b
|
|||
receipt of the hop that served naming the outputs it delivered. The attempts follow the order
|
||||
and only the last may serve; a failed hop, and a hop that served after the deadline the job
|
||||
declared, is not billed. A verifier recomputes the record from the three documents, holds each attempt to its
|
||||
receipt, and refuses a record whose choice they do not give. No transport and no payment:
|
||||
receipt, and refuses a record whose choice they do not give. A delivery of the job's output is
|
||||
routed the same way, from its own request, price book and policy, and its signed decision
|
||||
record states every route considered, the route taken and the key each other route was passed
|
||||
over by, names the job's record and the delivery decided before it, and is refused when its
|
||||
route is not the rule's or its chain is broken. No transport and no payment:
|
||||
[`extensions/route-record.md`](extensions/route-record.md).
|
||||
|
||||
And for **auditing a stream another service delivers** — `wai.delivery.audit`: a content owner's
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
### A channel's breaks marked in its HLS Media Playlist, and why a delivery took the route it took
|
||||
|
||||
- **The cue tags** ([extensions/channel-lineup.md](extensions/channel-lineup.md) §7.4): a packager that writes a
|
||||
channel's segments, MPEG-2 transport stream segments among them, marks each break of the `wai.video.channel`
|
||||
receipt's schedule, in two forms. `EXT-X-CUE-OUT` with the break's duration goes on its first segment,
|
||||
`EXT-X-CUE-OUT-CONT` with the time elapsed and the duration on each later one, and `EXT-X-CUE-IN` on its return.
|
||||
For a break whose cue carries its SCTE-35 section, the `EXT-X-DATERANGE` pair of RFC 8216 §4.3.2.7.1 is written
|
||||
as well: `SCTE35-OUT` and `PLANNED-DURATION` with the last segment before the break, kept while the window holds
|
||||
a segment of it (RFC 8216 §6.2.1), then `DURATION` on its return. A date range's `ID` is the break's id and its
|
||||
start, so a break id a schedule repeats on another day names another range. A segment carries the same
|
||||
`EXT-X-CUE` lines in every window that holds it. A break whose start or end falls inside a segment is refused
|
||||
`cue_unaligned`, and one whose id holds a double quote, a line feed or a carriage return, which no quoted-string
|
||||
can hold, `cue_unwritable`. A window's last segment ends before 10000-01-01T00:00:00Z: a break in the window
|
||||
starts at the latest where the window ends, so every date the playlist writes has four digits of year.
|
||||
- **Its verification** (§8.4): a receipt, the cues its import wrote (`cues.json`) and a playlist. Each cue is held
|
||||
to its break's id, start and duration, and through its cue id to a `splice_insert()`'s `splice_event_id`
|
||||
(`cues_malformed`, `cues_mismatch`). The playlist's window is read back (`playlist_malformed`), and its tags are
|
||||
rendered again and compared byte for byte (`playlist_mismatch`). The receipt signs each break's id, start and
|
||||
duration, which every tag is written from. It does not sign the section a date range carries, and §12 says so.
|
||||
- **The reference**: `channel_lineup::cue_tags` (`render`, `read_playlist`, `verify_playlist`), and
|
||||
`channel_lineup::import::break_cue`, the cue §4.8 yields for a break.
|
||||
- **The corpus** [`live-conformance/channels/`](live-conformance/channels/) gains the sample's cues and five more
|
||||
channels' receipts or cues. One is the JSON form with brk-1's section left out, whose receipt is the sample's;
|
||||
three name brk-1 with a double quote, a line feed and a carriage return, the last a receipt the operator signs
|
||||
itself, since an import refuses the id; and one is a channel scheduled at the end of the year 9999. It also
|
||||
gains 26 playlist windows and 28 playlist verifications. Eleven windows render playlists, among them one that
|
||||
begins inside a break, one at a return, one with no section, and segments of 6.006 seconds, whose spans state
|
||||
milliseconds and whose `TARGETDURATION` is 7, 6.006 rounded up. Four more sit at a bound of §7.4: a URI of 2 048
|
||||
bytes, a segment of 2^32 − 1 milliseconds, a media sequence number of 2^53 − 1, and a window that ends at
|
||||
9999-12-31T23:59:59.999Z where a break starts. Fifteen are refused: a control of each refusal of §7.4, and one
|
||||
past each bound. Eight verifications are verified, and each of the others is a control of one step.
|
||||
`channel_lineup_verify.py` renders and reads the playlists itself. It catches each of its 18 new checks dropped,
|
||||
among them one for each bound and one for each character no quoted-string holds, and 13 new mutated copies.
|
||||
- **Breaking:** `api:rust:wai::channel_lineup::Reason::CuesMalformed`,
|
||||
`api:rust:wai::channel_lineup::Reason::CuesMismatch`, `api:rust:wai::channel_lineup::Reason::PlaylistMalformed`,
|
||||
`api:rust:wai::channel_lineup::Reason::CueUnaligned`, `api:rust:wai::channel_lineup::Reason::CueUnwritable` and
|
||||
`api:rust:wai::channel_lineup::Reason::PlaylistMismatch`: six reasons, which an exhaustive match over `Reason`
|
||||
needs.
|
||||
- **The delivery-decision record** ([extensions/route-record.md](extensions/route-record.md) §6.3, §7.2 step 10),
|
||||
`wai-route-delivery-decision`: why a delivery of a job's output took the route it took. A delivery is routed as a
|
||||
job is, from its own request, price book of version 2 and policy, so its candidates are the routes that could
|
||||
carry it (an origin, a cache, a bearer), with every limit that removed one. The record restates every route as
|
||||
its price book stated it, with its estimates and expected figures. It states the order, the decision and the
|
||||
route taken, and in `passed` the key by which each other surviving route was passed over. It names its inputs by
|
||||
digest: the delivery's three documents and, as its `parent`, the job's route record, whose served hop's output
|
||||
it carries by count and root. Signed by the job's router under its own domain, it is chained: each decision after
|
||||
a job's first names the one before it by `prev`, so a decision left out before a later one, moved or out of
|
||||
order is refused. The list proves nothing of decisions after its last, since a verifier is not told how many
|
||||
there were.
|
||||
- **Its verification.** A verification document may have `deliveries`, each a delivery's three documents and its
|
||||
decision, the job's decisions from the first. Each is read and held to its documents (`request_mismatch`,
|
||||
`policy_mismatch`, `pricebook_mismatch`), and its routing is recomputed (`candidate_mismatch`, `removal_mismatch`,
|
||||
`rank_mismatch`, `decision_mismatch`). Its route and its passed routes are checked (`delivery_route_mismatch`),
|
||||
then its parent (`delivery_parent_mismatch`; a record of version 1, or one no hop served, has nothing to
|
||||
deliver). Last comes its chain (`delivery_chain_broken`: its place, its `prev`, a decision before the output
|
||||
existed or before the one before it, one delivery decided twice). A form that breaks §6.3 is `delivery_invalid`.
|
||||
No route record changes, and a document without `deliveries` reads exactly as before.
|
||||
- **The reference**: `route_record::delivery` (`seal`, `passed`, `delivered_by`, `form`); the route record's
|
||||
candidate form and step 6 are shared with it, unchanged.
|
||||
- **The corpus.** Routes gain 70 vectors: six accepted and 64 controls, each a control of one defect, refused at its
|
||||
own place. Each member that binds a decision to its documents has a control of its own: the request's id,
|
||||
operation, capability, fallback, quantities and digest, the policy's name, version and digest, and the price
|
||||
book's digest, currency and `issued`. The accepted ones are a delivery to Europe that one cache wins on time to
|
||||
first byte over another of one price, two decisions chained, a delivery no route survives, one under a record of
|
||||
version 2, one verified without receipts, and an empty `deliveries`. `route_record_verify.py` verifies each from
|
||||
the text. It catches each of its 31 new checks dropped and every mutated copy.
|
||||
- **The registry** lists `wai-route-delivery-decision` as a document kind of route-record §6.3 (REGISTRY.md and
|
||||
`codecs/registry.rs`).
|
||||
- **Breaking:** `api:rust:wai::route_record::Accepted::deliveries`: what the decisions state, `None` when the
|
||||
document has none. A struct literal of `Accepted` needs it.
|
||||
- **Breaking:** `api:rust:wai::route_record::Document::deliveries`: the deliveries' texts, `None` when the document
|
||||
has none. A struct literal of `Document` needs it.
|
||||
- **Breaking:** `api:rust:wai::route_record::Reason::DeliveryInvalid`,
|
||||
`api:rust:wai::route_record::Reason::DeliveryRouteMismatch`,
|
||||
`api:rust:wai::route_record::Reason::DeliveryParentMismatch` and
|
||||
`api:rust:wai::route_record::Reason::DeliveryChainBroken`: four reasons, which an exhaustive match over `Reason`
|
||||
needs.
|
||||
- **Breaking:** `api:rust:wai::route_record::Reason::ALL`: the list of §8's codes holds those four, an array of 29.
|
||||
A use that names its length needs the new one.
|
||||
|
|
@ -15,6 +15,14 @@
|
|||
> and its second implementation is `live-conformance/channel_lineup_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> This revision adds the **cue tags** of a Media Playlist of the channel (§7.4): a packager that
|
||||
> writes the channel's segments, MPEG-2 transport stream segments among them, marks each break of
|
||||
> the receipt's schedule with `EXT-X-CUE-OUT` and the break's duration, `EXT-X-CUE-OUT-CONT` on each
|
||||
> later segment of the break and `EXT-X-CUE-IN` on its return, and, for a break whose cue carries
|
||||
> its SCTE-35 section, with the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1; a verifier given
|
||||
> the receipt, its cues and the playlist recomputes the tags and compares (§8.4). An import, a
|
||||
> lineup and a guide are unchanged.
|
||||
>
|
||||
> Texts read for this revision, each at its source:
|
||||
>
|
||||
> - the XMLTV DTD, `xmltv.dtd`: its date and time format, and the elements `tv`, `channel`,
|
||||
|
|
@ -30,6 +38,11 @@
|
|||
> date range);
|
||||
> - ANSI/SCTE 35-1 2023r2: §9.6 (Table 5, `splice_info_section()`) and §9.7.3 (Table 10,
|
||||
> `splice_insert()`);
|
||||
> - RFC 8216 (HTTP Live Streaming): §4.2 (attribute lists), §4.3.2.1 (`EXTINF`), §4.3.2.6
|
||||
> (`EXT-X-PROGRAM-DATE-TIME`), §4.3.2.7 (`EXT-X-DATERANGE`), §4.3.2.7.1 (SCTE-35 in a date
|
||||
> range), §4.3.3.1, §4.3.3.2 and §4.3.3.4 (`EXT-X-TARGETDURATION`, `EXT-X-MEDIA-SEQUENCE`,
|
||||
> `EXT-X-ENDLIST`), §6.2.1 and §6.3.1 (the server's and the client's general rules), §7 (protocol
|
||||
> versions) and §8.10 (its example of a date range carrying SCTE-35);
|
||||
> - RFC 8259 (JSON), RFC 8785 (the JSON Canonicalization Scheme), and XML 1.0 for the subset of
|
||||
> §3.3.
|
||||
>
|
||||
|
|
@ -40,13 +53,15 @@
|
|||
In scope: the bounds and value forms an import reads (§3); the import of a channel's schedule,
|
||||
catalogue and ad breaks into a schedule, its assets and its cues (§4); the JSON carriage a
|
||||
`wai.video.channel` receipt travels in, which is revenue-share's (§5); the lineup, its rules, its signature and how it binds its
|
||||
schedule (§6); the guide, in XMLTV and as a now/next document (§7); verification of a receipt, a
|
||||
lineup and a guide together (§8); the operations that do each, with a worked example (§9).
|
||||
schedule (§6); the guide, in XMLTV and as a now/next document (§7), and the cue tags of a Media
|
||||
Playlist of the channel (§7.4); verification of a receipt, a
|
||||
lineup and a guide together (§8), and of a playlist's cue tags (§8.4); the operations that do
|
||||
each, with a worked example (§9).
|
||||
|
||||
Out of scope: a transport; how feeds travel between operators; playout, which turns a schedule
|
||||
into segments; the ad decision of a break, which [live-ads](live-ads.md) and `wai.video.ssai`
|
||||
make; the revenue an asset's rights holder is owed; the HLS cue tags that the HLS text does not
|
||||
define, and interstitial date ranges, which [stitched-presentation](stitched-presentation.md)
|
||||
make; the revenue an asset's rights holder is owed; how a packager cuts and encodes segments,
|
||||
which §7.4 takes as given; interstitial date ranges, which [stitched-presentation](stitched-presentation.md)
|
||||
reads; SCTE 224 audience and blackout rules (a lineup states where an asset may be shown, and
|
||||
says nothing about when); and the elements of the XMLTV DTD and of Media RSS that §4 does not
|
||||
name, which an import does not read.
|
||||
|
|
@ -587,6 +602,136 @@ A programme is an object with the members `start_ms`, `stop_ms` and `asset_id`;
|
|||
`{"system", "value"}`; `artwork`, an array of `{"sha256", "uri"}`; `geo`, `null` for everywhere,
|
||||
`{"allow": [...]}` or `{"deny": [...]}`; `rights_holder`; and `duration_ms`.
|
||||
|
||||
### 7.4 The Media Playlist's cue tags
|
||||
|
||||
A receipt also gives the ad markers of a Media Playlist of the channel (RFC 8216), so that a
|
||||
packager that writes the channel's segments, MPEG-2 transport stream segments among them, marks
|
||||
each break where the signed schedule places it, and anyone holding
|
||||
the receipt and its cues recomputes the markers and compares (§8.4). Each break is marked in two
|
||||
forms:
|
||||
|
||||
- the tags `EXT-X-CUE-OUT`, `EXT-X-CUE-OUT-CONT` and `EXT-X-CUE-IN`, in a form in common use, which
|
||||
no published standard defines and which this section fixes; and
|
||||
- for a break whose cue carries its section, the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1,
|
||||
which carry the SCTE-35 section (ANSI/SCTE 35-1 2023r2 §9.6) in `SCTE35-OUT`.
|
||||
|
||||
A client ignores a tag it does not recognise (RFC 8216 §6.3.1), so a client that reads one form
|
||||
plays a playlist that carries both.
|
||||
|
||||
**The breaks** are the receipt's `ad` entries of a duration above 0, in schedule order. A
|
||||
break's **id** is its entry's asset id, its **start** the entry's start, its **duration** the
|
||||
entry's duration, and its **end** its start plus its duration. Each break has its cue, the one an
|
||||
import wrote for it (§4.8, the `cues.json` of §9); its **section** is the cue's message when the
|
||||
cue is of form 1, and it has none when the cue is of form 0. The receipt signs each break's id,
|
||||
start and duration, which are all the cue tags are written from. It does not sign the section a
|
||||
date range carries: that is the cue's, which §8.4 holds to its break's id and duration and no
|
||||
further.
|
||||
|
||||
**The window** is what a Media Playlist lists: its media sequence number (RFC 8216 §4.3.3.2); the
|
||||
instant its first segment begins; its segments in order, each a URI and a duration, each beginning
|
||||
where the one before it ends; and whether it is **ended** (`EXT-X-ENDLIST`, RFC 8216 §4.3.3.4). A
|
||||
segment's URI is 1 to 2 048 bytes of ASCII from `!` to `~`, the first of which is not `#`, and its
|
||||
duration 1 to 2^32 − 1 milliseconds; the media sequence number is at most 2^53 − 1, and the last
|
||||
segment ends before 253 402 300 800 000, the first instant of the year 10000. A break in the window
|
||||
starts at the latest where the window ends, so every date below has four digits of year. A window
|
||||
that is not so is refused `playlist_malformed`.
|
||||
|
||||
A segment **starts in** a break when it starts at or after the break's start and before its end.
|
||||
A break's **last segment before it** is the segment that ends at its start, and its **return** the
|
||||
segment that starts at its end. A break **is in** the window when the window holds its last
|
||||
segment before it, a segment that starts in it, or its return. The tags are refused:
|
||||
|
||||
- `cue_unaligned`, at the first segment of the window, in order, inside which a break's start or
|
||||
end lies (after the segment's start and before its end): a marker applies from a segment's
|
||||
start, and the splice the schedule places would fall inside the segment;
|
||||
- then `cue_unwritable`, at the first break, among the breaks, that is in the window, has a
|
||||
section and has an id holding a double quote, a line feed or a carriage return, which no
|
||||
quoted-string can hold (RFC 8216 §4.2).
|
||||
|
||||
Otherwise the **playlist** of the window is these lines, each ended by a line feed:
|
||||
|
||||
```
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:TARGET
|
||||
#EXT-X-MEDIA-SEQUENCE:SEQUENCE
|
||||
#EXT-X-PROGRAM-DATE-TIME:FIRST
|
||||
```
|
||||
|
||||
then, for each segment of the window in order, its cue lines (below), `#EXTINF:DURATION,` and its
|
||||
URI; and then `#EXT-X-ENDLIST` when the window is ended.
|
||||
|
||||
- `TARGET` is the longest segment's duration in seconds, rounded up, so that each `EXTINF` rounded
|
||||
to the nearest integer is at most it (RFC 8216 §4.3.3.1); `SEQUENCE` the media sequence number in
|
||||
decimal, with no leading zero; and `FIRST` the first segment's start as a date.
|
||||
- A **date** is an instant written `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC, always three digits after the
|
||||
point (RFC 8216 §4.3.2.6); a **span** is a count of milliseconds written as seconds with three
|
||||
digits after the point (`90.000`). A segment's `DURATION` is its duration as a span: a decimal
|
||||
duration, which needs `EXT-X-VERSION` 3 (RFC 8216 §4.3.2.1, §7).
|
||||
|
||||
A segment's **cue lines** are, in this order:
|
||||
|
||||
1. The date range tags written at it, break by break in schedule order, of each break that has a
|
||||
section: its **out tag** at the first segment of the window that puts it in the window, and its
|
||||
**in tag** at its return, when the window holds it:
|
||||
|
||||
```
|
||||
#EXT-X-DATERANGE:ID="ID",START-DATE="START",PLANNED-DURATION=SPAN,SCTE35-OUT=0xSECTION
|
||||
#EXT-X-DATERANGE:ID="ID",START-DATE="START",DURATION=SPAN
|
||||
```
|
||||
|
||||
`ID` is the break's id, `@` and its start in decimal; `START` its start as a date; `SPAN` its
|
||||
duration as a span; and `SECTION` its section in uppercase hexadecimal, a hexadecimal-sequence
|
||||
(RFC 8216 §4.2).
|
||||
2. `#EXT-X-CUE-IN`, when a break ends at the segment's start.
|
||||
3. `#EXT-X-CUE-OUT:SPAN`, `SPAN` the break's duration, when a break starts at the segment's start;
|
||||
otherwise `#EXT-X-CUE-OUT-CONT:ElapsedTime=ELAPSED,Duration=SPAN`, when the segment starts in a
|
||||
break, `ELAPSED` the time from the break's start to the segment's as a span.
|
||||
|
||||
So:
|
||||
|
||||
- **A date range announces its splice and is kept while it maps a segment.** RFC 8216 §4.3.2.7.1
|
||||
places an out section in `SCTE35-OUT`, with a `START-DATE` that is the splice's and a
|
||||
`PLANNED-DURATION`, and has a tag announcing a splice added with the last segment before it; once
|
||||
the end is established, another tag of the same `ID` signals it. RFC 8216 §6.2.1 keeps a date range
|
||||
while any of its dates maps to a segment the playlist holds. Two tags of one `ID` agree on every
|
||||
attribute both carry (RFC 8216 §4.3.2.7), here `ID` and `START-DATE`, which that section requires
|
||||
of every date range. The in tag carries no `SCTE35-IN`: an import keeps the out section alone
|
||||
(§4.5 step 3).
|
||||
- **An `ID` names one break.** A channel's schedule may repeat a break's id on another day; its start
|
||||
makes the `ID` unique in any playlist, as RFC 8216 §4.3.2.7.1 requires of an id that may recur.
|
||||
- **The cue form is a function of each segment.** A segment's `EXT-X-CUE` lines depend on its own
|
||||
start and the schedule alone, so it carries the same ones in every window that holds it, and a
|
||||
player that joins inside a break reads, on the first segment it is given, how far into the break
|
||||
it is and how long the break is. The date range tags depend on the window, as RFC 8216 places them.
|
||||
- **A break with no section has no date range**: RFC 8216 §4.3.2.7.1 maps a section, and there is
|
||||
none to map. Its cue form is written all the same.
|
||||
- **Every playlist states its program date-time** (`FIRST`), which RFC 8216 §4.3.2.7 requires of a
|
||||
playlist that carries a date range, on the receipt's UTC timeline.
|
||||
- **Nothing is spliced.** The segments are the channel's own, the program and the ad entries
|
||||
alike; the tags say where a downstream inserter replaces the ad entries' segments. No
|
||||
`EXT-X-DISCONTINUITY` is written, since the window's segments run on one timeline.
|
||||
|
||||
For brk-1 of the corpus's sample (§11), 90 seconds from 18:12:00 UTC, in ten-second segments, the
|
||||
last segment before it, its first and its return carry:
|
||||
|
||||
```
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC30…
|
||||
#EXTINF:10.000,
|
||||
seg-71.ts
|
||||
#EXT-X-CUE-OUT:90.000
|
||||
#EXTINF:10.000,
|
||||
seg-72.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-73.ts
|
||||
…
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:10.000,
|
||||
seg-81.ts
|
||||
```
|
||||
|
||||
## 8. Verification
|
||||
|
||||
### 8.1 Inputs
|
||||
|
|
@ -639,6 +784,46 @@ difference: a title, a rating or a picture the lineup does not state, a programm
|
|||
receipt states which of it was on air in its window;
|
||||
- for `guide_entry_unscheduled`, the index of step 10.
|
||||
|
||||
### 8.4 A playlist's cue tags
|
||||
|
||||
A verifier given a receipt in its JSON carriage (§5), a cues document and a Media Playlist's bytes
|
||||
gets one verdict, **verified** or the reason of the first step it fails:
|
||||
|
||||
1. The receipt reads, as §8.2 step 1: `channel_receipt_malformed`.
|
||||
2. Its signature verifies, as §8.2 step 2: `bad_key`, `bad_signature`.
|
||||
3. Its rules hold, as §8.2 step 3: `channel_receipt_invalid`.
|
||||
4. **The cues** read: one JSON text (RFC 8259) in UTF-8, nested at most 64 deep, whose objects each
|
||||
name a member once, an array of objects of exactly `break` (a string), `start_ms` (an integer of
|
||||
at most 2^53 − 1, written as §4.4 writes one) and `payload` (an even count of lowercase
|
||||
hexadecimal digits), as `channel.import` writes `cues.json` (§9). Otherwise `cues_malformed`.
|
||||
5. They are the breaks' cues (§7.4): there are as many as there are breaks, and the cue at each index
|
||||
is its break's: its `break` the break's id and its `start_ms` the break's start, and its payload
|
||||
the one §4.8 yields for a break of that id and duration and of the section the payload carries,
|
||||
when it carries a message of form 1 that reads as §4.5 step 3 reads a section, or of none, when it
|
||||
is of form 0. Otherwise `cues_mismatch`, at the first index that is not, a cue missing or added at
|
||||
the first index past the shorter list. A break whose duration is above 2^32 − 1 milliseconds has no
|
||||
cue of §4.8, and its index fails.
|
||||
6. **The playlist** reads: UTF-8 text whose lines, split at each line feed, the last ended by one,
|
||||
begin with `#EXTM3U`; with exactly one line `#EXT-X-MEDIA-SEQUENCE:` and a decimal integer with no
|
||||
leading zero, and a line `#EXT-X-PROGRAM-DATE-TIME:` and an HLS date-time (§3.4) before its first
|
||||
segment; whose segments are each a line `#EXTINF:`, a duration (§3.4) and `,`, followed by a line,
|
||||
the segment's URI, at least one of them. It is ended when one of its lines is `#EXT-X-ENDLIST`. Its
|
||||
**window** is that media sequence number, the first such date-time's instant as its first
|
||||
segment's start, its segments in order and whether it is ended, which **MUST** hold to §7.4's
|
||||
bounds. Otherwise `playlist_malformed`. No other line is read here: step 8 compares every line.
|
||||
7. Its window's tags can be written (§7.4): `cue_unaligned`, at the segment's index; `cue_unwritable`,
|
||||
at the break's index among the breaks.
|
||||
8. Its bytes are the playlist of §7.4 of its window: `playlist_mismatch`.
|
||||
|
||||
Step 8 catches a marker the schedule does not place, at a segment or in a date range: a break
|
||||
another duration, another start or another section than its cue's, a tag left out, added or moved.
|
||||
|
||||
A verification states its verdict and the **part** it is about (`receipt` for steps 1 to 3, `cues`
|
||||
for steps 4 and 5, `playlist` for steps 6 to 8, none when verified); the receipt's identity, when it
|
||||
reads; for a playlist that reads, its window (its media sequence number, its first segment's start,
|
||||
how many segments it holds and whether it is ended) and the breaks in it, each with its id, its
|
||||
start, its duration and whether it has a date range; and the index of step 5 or step 7.
|
||||
|
||||
## 9. Operations and a worked example
|
||||
|
||||
Three operations of the WAI command line carry this text (the operation catalogue,
|
||||
|
|
@ -705,8 +890,15 @@ The codes this extension adds, each with where it is raised:
|
|||
| `guide_source_mismatch` | §8.2 step 9 |
|
||||
| `guide_entry_unscheduled` | §8.2 step 10 |
|
||||
| `guide_mismatch` | §8.2 step 11 |
|
||||
| `cues_malformed` | §8.4 step 4 |
|
||||
| `cues_mismatch` | §8.4 step 5 |
|
||||
| `playlist_malformed` | §7.4, §8.4 step 6 |
|
||||
| `cue_unaligned` | §7.4, §8.4 step 7 |
|
||||
| `cue_unwritable` | §7.4, §8.4 step 7 |
|
||||
| `playlist_mismatch` | §8.4 step 8 |
|
||||
|
||||
A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5).
|
||||
A verification also gives live-events' `bad_key` and `bad_signature` (§8.2 steps 2 and 5, §8.4
|
||||
step 2).
|
||||
|
||||
## 11. Conformance
|
||||
|
||||
|
|
@ -728,10 +920,38 @@ programmes, owners and pictures made for it. The vectors:
|
|||
order, a program-as-run the window does not give, a lineup that does not read, one another key
|
||||
signed, one for each rule of §6.5 that it alone breaks, a guide that does not read, one naming
|
||||
another schedule, one that places a programme the schedule does not, and one whose title the
|
||||
lineup does not state).
|
||||
lineup does not state);
|
||||
- **playlists**: windows of the sample's Media Playlist and the playlist each renders (§7.4): brk-1
|
||||
from before its last segment before it to after its return; a live window that begins inside
|
||||
brk-2, whose section is a `time_signal()`; brk-3, which begins half a second past a whole second,
|
||||
in six-second segments, the window ended; a window that begins at a return; one no break is in;
|
||||
and brk-1 from the JSON form with no section, which yields the same receipt and other cues, and
|
||||
no date range. And a control of each refusal of §7.4: segments cut off a break's start, a segment
|
||||
across a return, a break named with a double quote, and a segment of no duration;
|
||||
- **playlist windows at their bounds**: brk-1 in segments of 6.006 seconds, whose `EXTINF` and
|
||||
`ElapsedTime` state milliseconds and whose `TARGETDURATION` is 7, 6.006 rounded up; a segment URI
|
||||
of 2 048 bytes; a segment of 2^32 − 1 milliseconds; a media sequence number of 2^53 − 1; and, of
|
||||
a channel scheduled at the end of the year 9999, a window that ends at 9999-12-31T23:59:59.999Z,
|
||||
where a break starts, its date range dated in the year 9999. And a control of each bound: brk-1 named with a line feed, and
|
||||
with a carriage return in a receipt the operator signed (`cue_unwritable`); that window with a
|
||||
segment of a millisecond after it, which ends at 10000-01-01T00:00:00Z; a window of no segment; a
|
||||
segment URI of no byte, of 2 049 bytes, holding a space, holding a letter outside ASCII and
|
||||
beginning with `#`; a segment of 2^32 milliseconds; and a media sequence number of 2^53
|
||||
(`playlist_malformed`);
|
||||
- **playlist verifications**: the playlists of the first six windows, the one of 6.006-second
|
||||
segments and the one of the year 9999, each verified against its receipt and its cues (§8.4); and a
|
||||
control of each step: a receipt that does not read, a flipped signature, a
|
||||
program-as-run its window does not give, cues that do not read, a cue a millisecond longer than its
|
||||
break, one whose section names another `splice_event_id`, one a second late, one left out, another
|
||||
receipt's, a playlist with no media sequence number, one that ends at 10000-01-01T00:00:00Z, one
|
||||
whose segment URI begins with `#`, one whose segments move off a break's start,
|
||||
a break named with a double quote, and playlists that state another duration, leave out an
|
||||
`EXT-X-CUE-OUT-CONT`, return a segment early, carry another section, start a date range a second
|
||||
late, and leave out a date range's end.
|
||||
|
||||
A conforming implementation gives every import its outcome (the receipt, the lineup and the cues,
|
||||
or the refusal) and every verification its verdict and facts, as the corpus states.
|
||||
or the refusal), every verification its verdict and facts, every playlist window its playlist or
|
||||
its refusal, and every playlist verification its verdict and facts, as the corpus states.
|
||||
`wai_channel_lineup_vectors verify` and the test `tests/channel_lineup.rs` check that every file
|
||||
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that §10 lists exactly
|
||||
the codes the reference adds, and that the corpus gives every reason of §10 and both of
|
||||
|
|
@ -739,7 +959,8 @@ live-events'. `live-conformance/channel_lineup_verify.py` is the second implemen
|
|||
from this text: it shares no code with `wai-rs`; it verifies Ed25519 with WAI's strict rule
|
||||
(`claim-conformance/_ed25519.py`), BLAKE3 with `claim-conformance/_blake3.py`, and reads a channel
|
||||
receipt's carriage, its signed bytes, its identity and its rules as the revenue-share corpus's
|
||||
second implementation reads them (§5); it reads XML, JSON and playlists itself; and it mutates copies of the corpus and requires
|
||||
second implementation reads them (§5); it reads XML, JSON and playlists itself, and renders and
|
||||
reads a Media Playlist's cue tags itself (§7.4, §8.4); and it mutates copies of the corpus and requires
|
||||
every copy to fail, and drops each of its own checks in turn and requires the corpus to catch the
|
||||
weakened verifier.
|
||||
|
||||
|
|
@ -759,6 +980,13 @@ weakened verifier.
|
|||
- **Bounded reading.** An import reads bounded documents (§3.1), XML to a bounded depth with no
|
||||
document type declaration that defines anything (§3.3), so no entity is ever expanded, and
|
||||
integers it reads are checked against their bounds before any arithmetic.
|
||||
- **What the cue tags prove.** A playlist that verifies marks each break where the receipt's
|
||||
signed schedule places it, with its signed duration, and every segment of it; one that moves a
|
||||
break, shortens it or leaves a segment of it unmarked is refused. The section a date range carries
|
||||
is the import's (§4.8): the receipt does not sign it, and a verifier holds it only to its break's
|
||||
id and duration, and through the cue id to a `splice_insert()`'s `splice_event_id`. A party that
|
||||
splices on a section's own fields holds the playlist it was imported from, or the signed `cue`
|
||||
events of a room that carried it (live-events §3.2, §4.3), which sign the section itself.
|
||||
- **Availability.** Where an asset may be shown is the operator's statement of the catalogue's
|
||||
restriction; a player enforces it, or a delivery that a `wai.video.manifest` describes does.
|
||||
A lineup holds no viewer's data.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@
|
|||
> one hop, what came back and what the provider bills, signed by the router and bound to the
|
||||
> hop's attempt and receipt. A verification document without one is read exactly as before.
|
||||
>
|
||||
> This revision adds the **delivery-decision record** (§6.3): why a delivery of a job's output
|
||||
> took the route it took. A delivery is routed as a job is, from its own request, price book and
|
||||
> policy (§3, §4); its record states every route considered with the figures its price book stated
|
||||
> and the limits that removed it, the rule that chose and by which key each other route was passed
|
||||
> over, and every input it read by digest. It is signed by the job's router, names the job's
|
||||
> route record as its parent and the delivery decided before it, and holds what it delivers to
|
||||
> what the job's served hop delivered. A verification document without one is read exactly as
|
||||
> before.
|
||||
>
|
||||
> Texts read for this revision: the WAI texts this one cites; RFC 8259; RFC 8785; RFC 8032;
|
||||
> ISO 4217 (the alphabetic currency code); FIPS 180-4; the BLAKE3 specification.
|
||||
|
||||
|
|
@ -46,8 +55,9 @@ In scope: the three documents a routing decision rests on, the request, the pric
|
|||
the policy (§3); how a router computes its candidates, their removals, their ranking and
|
||||
its decision from them (§4); the record, its attempts and its signature (§5); how an
|
||||
attempt is held to its hop's OER/2 receipt, and in versions 2 and 3 to the outputs it
|
||||
delivered (§6); an output's inclusion proof (§6.1); verification (§7); and the reference
|
||||
implementation and corpus (§10).
|
||||
delivered (§6); an output's inclusion proof (§6.1); a third party's hop (§6.2); a delivery's
|
||||
decision, routed as a job is and chained to its job's record (§6.3); verification (§7); and the
|
||||
reference implementation and corpus (§10).
|
||||
|
||||
Out of scope: a transport; how a router learns its prices and measures its endpoints'
|
||||
failures, which the price book states and this extension takes as stated; accounts, credit
|
||||
|
|
@ -601,6 +611,88 @@ record's usage (receipt-ledger §4.9). The record states no energy figure: the
|
|||
hop's receipt states it, or the unmetered marker where the provider gave none
|
||||
(oer-receipts §3.1).
|
||||
|
||||
### 6.3 A delivery's decision
|
||||
|
||||
A job's output is then delivered: sent to who asked for it, by one of the routes that could carry
|
||||
it, an origin, a cache or a bearer among them. A router that routes a delivery reads the
|
||||
delivery's own documents, of §3's forms: its **request**, whose `job` is the delivery's id and
|
||||
whose `quantities` are what the delivery carries, unit by unit (`bytes`, for one); its **price
|
||||
book**, of version 2, whose entries are the routes, each priced for the operation it performs;
|
||||
and its **policy**. From them §4 computes the delivery's candidates, every limit that removes one,
|
||||
the ranking, the order and the decision, as it computes a job's. The router states why the
|
||||
delivery took the route it took in a **delivery-decision record**, a document (§2) of exactly these
|
||||
members:
|
||||
|
||||
| member | value |
|
||||
|---|---|
|
||||
| `kind` | `"wai-route-delivery-decision"` |
|
||||
| `version` | `1` |
|
||||
| `job` | the job's id, as its route record states it |
|
||||
| `parent` | the job's route record's digest (§5.3) |
|
||||
| `seq` | an integer at least 1: its place among the job's delivery decisions |
|
||||
| `prev` | `null`, or 64 lowercase hexadecimal digits: the digest of the job's decision of `seq` − 1, and `null` for its first (*its chain*, below) |
|
||||
| `objects` | an object of exactly `count`, an integer at least 1, and `root`, 64 lowercase hexadecimal digits: what the delivery carries, the objects the job's served hop delivered, by their count and their commitment (§6) |
|
||||
| `delivery` | a token: the delivery's request's `job`, the delivery's id |
|
||||
| `operation`, `capability`, `fallback`, `quantities` | the delivery's request's, of the forms §3.1 gives them |
|
||||
| `request` | 64 lowercase hexadecimal digits: the delivery's request's digest |
|
||||
| `policy` | an object of exactly `name`, `version` and `digest`: the delivery's policy's, of the forms §5.1 gives them |
|
||||
| `pricebook` | an object of exactly `digest`, `currency` and `issued`: the delivery's price book's, of the forms §5.1 gives them |
|
||||
| `candidates` | the delivery's candidates (§4.1), in their order, 0 to 1024 objects each of the members and forms of a candidate of a record of version 2 (§5.1) |
|
||||
| `order` | the order (§4.4): 0 to 1024 tokens, the surviving routes' endpoints |
|
||||
| `decision` | an object of exactly `decided_by` and `runner_up`, of the forms §5.1 gives them (§4.5) |
|
||||
| `route` | a token, the route the delivery took: the first endpoint of the order; `null` when no route survives |
|
||||
| `passed` | 0 to 1023 objects of exactly `endpoint`, a token, and `key`, a key of the key list (§4.4): for each survivor after the first, in the order, the first key on which it and the first differ, by which it was passed over |
|
||||
| `decided` | a time: when the router decided |
|
||||
| `signer`, `sig` | the router's key and its signature |
|
||||
|
||||
A record that is not of these members, each of its form, is `delivery_invalid`; the relations
|
||||
between its members, and between it and its documents, its parent and the decisions before it, are
|
||||
those below.
|
||||
|
||||
**Why, in full.** The record restates each route as its price book stated it, with its estimates,
|
||||
its expected figures and every limit that removed it; it names the rule that chose, the policy by
|
||||
its name, version and digest, and for every survivor the key that ranked the route the delivery
|
||||
took above it, the runner-up's being the decision's `decided_by`; and it names by digest every
|
||||
input the rule read: the delivery's three documents and, by its parent, the job and the objects it
|
||||
delivers. A verifier recomputes all of it (§7.2 step 10), so a router cannot state a route the
|
||||
rule did not choose, nor a reason it did not have.
|
||||
|
||||
**Signed and chained.** The record is signed by the job's router, over the domain
|
||||
`wai:route-delivery-decision\x01` and the canonical form of the record without `sig`, under the
|
||||
strict rule of SPEC §7, and named by its digest (§2), over its canonical form with `sig`. It is
|
||||
chained to its job: `parent` names the job's route record, and each decision after the first
|
||||
names the one before it by `prev`, so a job's decisions are one sequence from its record, in which
|
||||
a decision left out before a later one, moved or given out of order is refused. The sequence proves
|
||||
nothing of decisions after its last: a verifier is not told how many deliveries were decided, and a
|
||||
list that ends early verifies.
|
||||
|
||||
Given the job's route record and its decisions, from the first and in order (§7.2 step 10), a
|
||||
decision is held to:
|
||||
|
||||
- **its documents**, as step 5 holds a record to its job's: its `delivery`, `operation`,
|
||||
`capability`, `fallback`, `quantities` and `request` are the delivery's request's `job`,
|
||||
`operation`, `capability`, `fallback`, `quantities` and digest (`request_mismatch`); its `policy`
|
||||
the delivery's policy's (`policy_mismatch`); and its `pricebook` the delivery's price book's
|
||||
digest, currency and `issued`, the book of version 2 (`pricebook_mismatch`);
|
||||
- **§4 over them**, as step 6 holds a record's candidates, order and decision (`candidate_mismatch`,
|
||||
`removal_mismatch`, `rank_mismatch`, `decision_mismatch`); and its `route` the first of the order,
|
||||
or `null` for none, and its `passed` each later survivor's endpoint with its key, in the order
|
||||
(`delivery_route_mismatch`);
|
||||
- **its parent**: its `signer` and `job` are the route record's, its `parent` the route record's
|
||||
digest, and its `objects` what the route record's served attempt delivered: in a record of
|
||||
version 3, its `delivered`; in one of version 2, the count of its `outputs` and their commitment
|
||||
(§6). A record of version 1 states no output, and one no hop served delivered nothing, so neither
|
||||
has a delivery. Otherwise `delivery_parent_mismatch`;
|
||||
- **its chain**: its `seq` is its place in the list, from 1; its `prev` is `null` for the first and
|
||||
the digest of the decision before it for every other; its `decided` is at least the served
|
||||
attempt's `end`, when the objects first existed, and at least the `decided` of the decision before
|
||||
it; and its `delivery` is not that of a decision before it. Otherwise `delivery_chain_broken`.
|
||||
|
||||
The route a delivery took is a decision, not a delivery: whether the route carried the objects is
|
||||
for the delivery's own receipts to state ([bearer-receipts](bearer-receipts.md)), and what it costs
|
||||
for the tariff that bills it (receipt-ledger §4). The record states no energy figure and no amount
|
||||
of its own, only the figures its price book stated and the estimates §4 derives from them.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
### 7.1 Inputs
|
||||
|
|
@ -610,9 +702,13 @@ A verifier is given a **verification document**: a JSON object of exactly `reque
|
|||
`null` or a list of strings each holding an OER/2 receipt's text, and `keys`, `null` or an
|
||||
object of a receipt's `chain.signer` names to keys, as oer-receipts §5 publishes them. It may
|
||||
also have `third_party`, a list of strings each holding a third-party hop record's text (§6.2).
|
||||
It may also have `deliveries`, a list of objects of exactly `request`, `pricebook`, `policy` and
|
||||
`decision`, each a string holding that document's text: a delivery's three documents and its
|
||||
delivery-decision record (§6.3), the job's decisions from the first, in order.
|
||||
The texts are carried as strings so that each is read as written. A verification document that
|
||||
is not such an object, whose `receipts` is a list while its `keys` is `null`, or that has
|
||||
`third_party` while its `receipts` is `null`, is `malformed`.
|
||||
`third_party` while its `receipts` is `null`, is `malformed`, and so is one whose `deliveries` is
|
||||
not such a list.
|
||||
|
||||
### 7.2 Steps
|
||||
|
||||
|
|
@ -667,6 +763,15 @@ and where (`at`, a list: the member, then indexes or members within it):
|
|||
is `third_party_invalid`; then its signature, `bad_key`, `bad_signature`, at `sig` within
|
||||
it; then its binding, `third_party_mismatch`, at the member; then what its hop delivered,
|
||||
`third_party_output_mismatch`, at `outputs` within it.
|
||||
10. **The delivery decisions** (§6.3), when the document has `deliveries`, each in turn, at
|
||||
`deliveries`, its index and the member: its `request`, `pricebook` and `policy`, as steps 1 to 3
|
||||
read the job's (`malformed`, `request_invalid`, `pricebook_invalid`, `policy_invalid`); then its
|
||||
`decision`: a text that is not a document, or whose `kind` is not
|
||||
`wai-route-delivery-decision`, is `malformed`; a `version` that is an integer other than `1` is
|
||||
`unknown_version`, at `version` within it; one that is not of §6.3's members and forms is
|
||||
`delivery_invalid`; then its signature, `bad_key`, `bad_signature`, at `sig` within it; then, at
|
||||
the member within it, its documents, §4 over them (a candidate at `candidates` and its index),
|
||||
its route, its parent and its chain, in that order and each as §6.3 holds it.
|
||||
|
||||
### 7.3 Outcome
|
||||
|
||||
|
|
@ -680,7 +785,9 @@ and where (`at`, a list: the member, then indexes or members within it):
|
|||
limits that removed it; whether the hop receipts were `checked` or `not_checked`; and, when
|
||||
checked, the hops whose receipts state the unmetered marker (oer-receipts §3.1), which a
|
||||
reader **MUST NOT** present as hops of zero joules. When the document has `third_party`, each
|
||||
record's hop, digest, `account`, `usage` and `charge`, in the order of their hops.
|
||||
record's hop, digest, `account`, `usage` and `charge`, in the order of their hops. When it has
|
||||
`deliveries`, each decision's `seq`, `delivery`, digest, `route`, `decision` and `passed`, in
|
||||
their order.
|
||||
|
||||
## 8. Reasons
|
||||
|
||||
|
|
@ -713,6 +820,10 @@ The codes this extension adds, each with where it is raised:
|
|||
| `third_party_invalid` | §6.2, §7.2 step 9 |
|
||||
| `third_party_mismatch` | §6.2, §7.2 step 9 |
|
||||
| `third_party_output_mismatch` | §6.2, §7.2 step 9 |
|
||||
| `delivery_invalid` | §6.3, §7.2 step 10 |
|
||||
| `delivery_route_mismatch` | §6.3, §7.2 step 10 |
|
||||
| `delivery_parent_mismatch` | §6.3, §7.2 step 10 |
|
||||
| `delivery_chain_broken` | §6.3, §7.2 step 10 |
|
||||
|
||||
A verifier of this extension also raises `malformed` for a text that is not a document (§2)
|
||||
or a verification document that is not §7.1's, `unknown_version` for a record of another
|
||||
|
|
@ -785,6 +896,30 @@ verification document with the outcome §7.3 gives it:
|
|||
another unit, times outside the attempt's and a second record of a hop
|
||||
(`third_party_mismatch`); and outputs that are not what the receipt names, the same in another
|
||||
order, and another number than the attempt's count (`third_party_output_mismatch`).
|
||||
- **Accepted, delivery decisions**, under records of versions 2 and 3: a job's output delivered to
|
||||
Europe by the faster of two caches of one price, the dearer cache and the origin passed over by
|
||||
price, the second cache by time to first byte; a second delivery of it to another region by the
|
||||
one route that keeps no data there, chained to the first; a delivery no route survives; a record
|
||||
verified without its receipts; and a document whose `deliveries` is empty.
|
||||
- **Refused, delivery decisions**: a `deliveries` that is not a list, an entry without its policy
|
||||
and a decision not as text (`malformed`); the delivery's request, price book and policy each
|
||||
invalid; a decision that is not a document, of another kind and of version 2; a member missing and
|
||||
one added, a `seq` of 0, a `prev` in capitals, no object, `objects` with a member besides, a route
|
||||
passed by no key, a route as a number, a candidate of version 1's members, `decided` as text and
|
||||
more routes passed than a list holds (`delivery_invalid`); a decision changed after it was signed,
|
||||
a signer of small order and one signed under the route record's domain; another delivery's id,
|
||||
operation, capability, quantities and request digest, and a fallback the request does not name
|
||||
(`request_mismatch`); another policy version, name and digest (`policy_mismatch`); another price
|
||||
book's `issued`, digest and currency, and one of version 1 (`pricebook_mismatch`); a route's price
|
||||
restated, a route left out, a removal left out, two ranks swapped, the order reversed and another
|
||||
`decided_by`; another route, none while one survives, a route passed by another key, a passed
|
||||
route left out, a removed route passed, and a route where none survives
|
||||
(`delivery_route_mismatch`); another router's decision, another job's, another parent, another
|
||||
count and root, and a decision of a job no hop served and of a record of version 1
|
||||
(`delivery_parent_mismatch`); and a first decision at `seq` 2 and one naming a `prev`, a second
|
||||
with no `prev` and one naming another, decisions out of order, the first left out before the
|
||||
second, one decided before the output existed and one before the decision before it, and one
|
||||
delivery decided twice (`delivery_chain_broken`).
|
||||
|
||||
`wai_route_record_vectors verify` and the test `tests/route_record.rs` check that the file
|
||||
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of
|
||||
|
|
@ -795,7 +930,8 @@ written from RFC 8032 (`claim-conformance/_ed25519.py`), verifies the hop receip
|
|||
OER/2 reference verifier (`oer-conformance/oer2_verify.py`), computes the outputs'
|
||||
commitment with BLAKE3 written from its specification (`claim-conformance/_blake3.py`),
|
||||
recomputes every candidate, removal, expected figure, rank, order, decision and attempt
|
||||
itself, gives every vector the same outcome, requires every mutated copy of the corpus to
|
||||
itself, and every delivery's routing, route, passed routes, parent and chain, gives every
|
||||
vector the same outcome, requires every mutated copy of the corpus to
|
||||
fail, and requires the corpus to catch the verifier with each of its checks dropped.
|
||||
|
||||
## 11. Security and privacy considerations
|
||||
|
|
@ -839,8 +975,22 @@ fail, and requires the corpus to catch the verifier with each of its checks drop
|
|||
answers: the provider signs nothing, so the record is the router's statement of what passed
|
||||
between them, bound by digest to the bytes, which a holder of them checks. A provider's
|
||||
`charge` is what the provider reported, as the router read it, and is never a price.
|
||||
- **A delivery's route is the rule's.** A delivery-decision record whose route, or whose reason for
|
||||
passing a route over, is not what §4 gives over the delivery's own documents is refused
|
||||
(`delivery_route_mismatch`), so a router that sends a delivery to a route its rule did not choose
|
||||
cannot sign a record that verifies. Each decision names the job's route record and the decision
|
||||
before it, so one left out before a later one, moved or put in another job's deliveries is refused
|
||||
(`delivery_chain_broken`, `delivery_parent_mismatch`). The decisions after the last one given are
|
||||
not proved absent: a verifier is not told how many there were.
|
||||
- **What a delivery decision proves.** That the router, holding the delivery's three documents,
|
||||
chose its route by their rules, for the output the job's served hop delivered. It does not prove
|
||||
that a route's terms are as its price book states, which a holder of the published price book of
|
||||
that digest compares, nor that the route carried the objects, which the delivery's own receipts
|
||||
state.
|
||||
- **What a record reveals.** Its job id, its operation and its quantities, every endpoint and
|
||||
provider that could have served the job with their prices, and when each hop ran; in
|
||||
version 2, every candidate's measured failures and the content hashes of the job's
|
||||
outputs. A third-party hop record reveals, besides, what the provider answered about the job.
|
||||
outputs. A third-party hop record reveals, besides, what the provider answered about the job,
|
||||
and a delivery decision every route that could have carried the job's output, with its terms,
|
||||
and where the delivery was to go, by the regions and residencies its request names.
|
||||
They travel between the router, the job's owner and an auditor they choose.
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@ stitched_presentation_verify.py. Everything else is here, from the standard libr
|
|||
- 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 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. Of the
|
||||
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
|
||||
|
|
@ -1196,6 +1200,256 @@ def verify(receipt, lineup, guide):
|
|||
|
||||
# ---------------------------------------------------------------- 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:
|
||||
|
|
@ -1269,6 +1523,10 @@ def corpus_files(doc):
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -1290,9 +1548,25 @@ def check_all(doc, files, listing, quiet=False):
|
|||
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)
|
||||
|
|
@ -1349,6 +1623,21 @@ def mutations(doc, files, listing):
|
|||
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
|
||||
|
||||
|
||||
|
|
@ -1357,7 +1646,8 @@ def main(corpus_dir):
|
|||
doc, files, listing = load(corpus_dir)
|
||||
if check_all(doc, files, listing):
|
||||
return 1
|
||||
print("channel lineup corpus: %d imports and %d verifications hold" % (len(doc["imports"]), len(doc["verifications"])))
|
||||
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):
|
||||
|
|
@ -1367,7 +1657,10 @@ def main(corpus_dir):
|
|||
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"]
|
||||
"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}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
The corpus of [`extensions/channel-lineup.md`](../../extensions/channel-lineup.md) §11: a sample
|
||||
linear channel as operators exchange it, imported into its `wai.video.channel` receipt and its
|
||||
`WLU1` lineup, its guides, and what a verifier states of each. Every file but this one is written
|
||||
`WLU1` lineup, its guides, the cue tags of windows of its Media Playlist, and what a verifier
|
||||
states of each. Every file but this one is written
|
||||
from fixed seeds by `wai_channel_lineup_vectors gen` (feature `channel_lineup`), and
|
||||
`wai_channel_lineup_vectors verify <dir>` and `tests/channel_lineup.rs` require them byte for byte.
|
||||
The second implementation is [`../channel_lineup_verify.py`](../channel_lineup_verify.py), run as
|
||||
|
|
@ -23,6 +24,13 @@ three content owners, with four ad breaks:
|
|||
| `sample.now-next.json`, `sample.before.now-next.json`, `sample.after.now-next.json` | now/next documents inside a break, before the schedule and after it |
|
||||
| `media_rss_loop.channel.json` | the receipt of one owner's items alone, back to back |
|
||||
| `aired-misreported.channel.json` | a receipt, signed, whose program-as-run its window does not give |
|
||||
| `sample.cues.json` | the cues the sample's import writes (`cues.json`, §9) |
|
||||
| `no-section.cues.json` | the cues of the same channel from the JSON form with brk-1's section left out: its receipt is the sample's |
|
||||
| `quoted.channel.json`, `quoted.cues.json` | the receipt and cues of the same channel from the JSON form with brk-1 named `brk"1` |
|
||||
| `line-feed.channel.json`, `line-feed.cues.json` | the receipt and cues of the same channel from the JSON form with brk-1 named with a line feed |
|
||||
| `carriage-return.channel.json`, `carriage-return.cues.json` | the same with brk-1 named with a carriage return, which an import refuses: the operator signs that receipt itself |
|
||||
| `far.channel.json`, `far.cues.json` | a channel scheduled at the end of the year 9999: a programme to 9999-12-31T23:59:59.999Z, a break of a millisecond from then, and one from 10000-01-01T00:00:00Z |
|
||||
| `sample.out-in.m3u8`, `sample.inside.m3u8`, `sample.offset.m3u8`, `sample.return.m3u8`, `sample.none.m3u8`, `sample.no-section.m3u8`, `sample.fractional.m3u8`, `sample.longest-uri.m3u8`, `sample.longest-segment.m3u8`, `sample.last-sequence.m3u8`, `far.m3u8` | the Media Playlists the windows of `playlists` render, their breaks' cue tags written (§7.4) |
|
||||
| `vectors.json` | the operator's key, and every vector |
|
||||
|
||||
`vectors.json` holds:
|
||||
|
|
@ -37,7 +45,16 @@ three content owners, with four ad breaks:
|
|||
- `verifications`: each with its `receipt` file, its `lineup` bytes in hexadecimal, its `guide`
|
||||
file or `null`, its `edits`, and its `facts`, what §8.3 states: `verdict`, `part`, `receipt`,
|
||||
`lineup`, `guide` (`format`, `from_ms`, `to_ms`, `at_ms`, and its `programmes`, each
|
||||
`start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`.
|
||||
`start_ms`, `stop_ms`, `asset_id` and `aired`), and `entry`;
|
||||
- `playlists`: each with its `receipt` and `cues` files, its `window` (`media_sequence`,
|
||||
`start_ms`, its `segments`, each `uri` and `duration_ms`, and `ended`), and its `outcome`:
|
||||
`{"playlist": file}`, the playlist the window renders byte for byte, or `{"refused": reason,
|
||||
"index": n}` (§7.4: the segment's index for `cue_unaligned`, the break's for `cue_unwritable`,
|
||||
`null` for `playlist_malformed`);
|
||||
- `playlist_verifications`: each with its `receipt`, `cues` and `playlist` files, its `edits`, and
|
||||
its `facts`, what §8.4 states: `verdict`, `part`, `receipt`, `window` (`media_sequence`,
|
||||
`start_ms`, `segments`, the count, `ended`, and its `breaks`, each `break`, `start_ms`,
|
||||
`duration_ms` and `scte35`, whether it has a date range), and `index`.
|
||||
|
||||
An edit is applied to a copy of the file it names before the vector is run: `{"file", "find",
|
||||
"replace"}` replaces the one occurrence of `find` (an edit whose `find` does not occur exactly
|
||||
|
|
|
|||
96
wai/live-conformance/channels/carriage-return.channel.json
Normal file
96
wai/live-conformance/channels/carriage-return.channel.json
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"channel_id": "sample-1",
|
||||
"schedule_root": "2a0d787631e0382342f428d60f5d8c3da1733dbfdeaf2373953ca1fd5bf5e5d3",
|
||||
"schedule": [
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793296800000,
|
||||
"duration_ms": 720000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk\r1",
|
||||
"start_ms": 1793297520000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793297610000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"duration_ms": 120000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793298600000,
|
||||
"duration_ms": 930500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"duration_ms": 60000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793299590500,
|
||||
"duration_ms": 809500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793300400000,
|
||||
"duration_ms": 840000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793301330000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "nf-1",
|
||||
"start_ms": 1793302200000,
|
||||
"duration_ms": 1800000,
|
||||
"kind": "program"
|
||||
}
|
||||
],
|
||||
"window_start_ms": 1793296800000,
|
||||
"window_end_ms": 1793300400000,
|
||||
"aired": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"work_schedule_ops": 11,
|
||||
"joules_micro": 0,
|
||||
"energy_provenance": null,
|
||||
"grant": {
|
||||
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"capability": "video.channel",
|
||||
"joule_ceiling_micro": 18446744073709551615,
|
||||
"funds_ceiling": null
|
||||
},
|
||||
"parent_receipt_hash": null,
|
||||
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
|
||||
"signer_id": "sample-operator",
|
||||
"sig": "0e34af1c40ea15e2662ec7ee0317831637a7ce81e1951dbb29519379a44ce893d272d05b84b58cbf4dbe4d574f44e3c05bc3b0ccb89eb4b9413982ef56c7a701"
|
||||
}
|
||||
22
wai/live-conformance/channels/carriage-return.cues.json
Normal file
22
wai/live-conformance/channels/carriage-return.cues.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"break": "brk\r1",
|
||||
"start_ms": 1793297520000,
|
||||
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
|
||||
},
|
||||
{
|
||||
"break": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
|
||||
},
|
||||
{
|
||||
"break": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
|
||||
}
|
||||
]
|
||||
44
wai/live-conformance/channels/far.channel.json
Normal file
44
wai/live-conformance/channels/far.channel.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"channel_id": "far",
|
||||
"schedule_root": "0022cbb66de5c21d9bb6c6eebda6f5f653dfaf600e6f3c0482b620b853759138",
|
||||
"schedule": [
|
||||
{
|
||||
"asset_id": "far-prog",
|
||||
"start_ms": 253402300739999,
|
||||
"duration_ms": 60000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "far-1",
|
||||
"start_ms": 253402300799999,
|
||||
"duration_ms": 1,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "far-2",
|
||||
"start_ms": 253402300800000,
|
||||
"duration_ms": 30001,
|
||||
"kind": "ad"
|
||||
}
|
||||
],
|
||||
"window_start_ms": 253402300739999,
|
||||
"window_end_ms": 253402300830001,
|
||||
"aired": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"work_schedule_ops": 3,
|
||||
"joules_micro": 0,
|
||||
"energy_provenance": null,
|
||||
"grant": {
|
||||
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"capability": "video.channel",
|
||||
"joule_ceiling_micro": 18446744073709551615,
|
||||
"funds_ceiling": null
|
||||
},
|
||||
"parent_receipt_hash": null,
|
||||
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
|
||||
"signer_id": "sample-operator",
|
||||
"sig": "26c0fb2a6cb471d6add0e3dcb359e5e37e1e9fd544255af095d2fa5b846420ab021b7ac48454d5a4f0cc58635618b024a4393b51a4a459b03b7e6bd04c3f910a"
|
||||
}
|
||||
12
wai/live-conformance/channels/far.cues.json
Normal file
12
wai/live-conformance/channels/far.cues.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[
|
||||
{
|
||||
"break": "far-1",
|
||||
"start_ms": 253402300799999,
|
||||
"payload": "04a0e4f10101000000011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "far-2",
|
||||
"start_ms": 253402300800000,
|
||||
"payload": "048ea34d3031750000011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
}
|
||||
]
|
||||
8
wai/live-conformance/channels/far.m3u8
Normal file
8
wai/live-conformance/channels/far.m3u8
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:1000
|
||||
#EXT-X-PROGRAM-DATE-TIME:9999-12-31T23:59:49.999Z
|
||||
#EXT-X-DATERANGE:ID="far-1@253402300799999",START-DATE="9999-12-31T23:59:59.999Z",PLANNED-DURATION=0.001,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F
|
||||
#EXTINF:10.000,
|
||||
seg-1000.ts
|
||||
96
wai/live-conformance/channels/line-feed.channel.json
Normal file
96
wai/live-conformance/channels/line-feed.channel.json
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"channel_id": "sample-1",
|
||||
"schedule_root": "fb34463ff9b7c9abf29716dd3aec6ca2d099f1b60cd69ca166d82f8d69f45cfa",
|
||||
"schedule": [
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793296800000,
|
||||
"duration_ms": 720000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk\n1",
|
||||
"start_ms": 1793297520000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793297610000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"duration_ms": 120000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793298600000,
|
||||
"duration_ms": 930500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"duration_ms": 60000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793299590500,
|
||||
"duration_ms": 809500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793300400000,
|
||||
"duration_ms": 840000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793301330000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "nf-1",
|
||||
"start_ms": 1793302200000,
|
||||
"duration_ms": 1800000,
|
||||
"kind": "program"
|
||||
}
|
||||
],
|
||||
"window_start_ms": 1793296800000,
|
||||
"window_end_ms": 1793300400000,
|
||||
"aired": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"work_schedule_ops": 11,
|
||||
"joules_micro": 0,
|
||||
"energy_provenance": null,
|
||||
"grant": {
|
||||
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"capability": "video.channel",
|
||||
"joule_ceiling_micro": 18446744073709551615,
|
||||
"funds_ceiling": null
|
||||
},
|
||||
"parent_receipt_hash": null,
|
||||
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
|
||||
"signer_id": "sample-operator",
|
||||
"sig": "364c62c19d4d6d1b4b585b677603c2f3751f1aebacd7af81a271b482144bccc162de1e14af3c157fe8d5a6321bb38573d9cc0fbe70a9b87027a75d40705eee0e"
|
||||
}
|
||||
22
wai/live-conformance/channels/line-feed.cues.json
Normal file
22
wai/live-conformance/channels/line-feed.cues.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"break": "brk\n1",
|
||||
"start_ms": 1793297520000,
|
||||
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
|
||||
},
|
||||
{
|
||||
"break": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
|
||||
},
|
||||
{
|
||||
"break": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
|
||||
}
|
||||
]
|
||||
22
wai/live-conformance/channels/no-section.cues.json
Normal file
22
wai/live-conformance/channels/no-section.cues.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"break": "brk-1",
|
||||
"start_ms": 1793297520000,
|
||||
"payload": "04cf47598c905f010000"
|
||||
},
|
||||
{
|
||||
"break": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
|
||||
},
|
||||
{
|
||||
"break": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
|
||||
}
|
||||
]
|
||||
96
wai/live-conformance/channels/quoted.channel.json
Normal file
96
wai/live-conformance/channels/quoted.channel.json
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"channel_id": "sample-1",
|
||||
"schedule_root": "170673c8908c8a3699ff6c72a0999fbddd0d4d5106863fbfd391c84fe0822270",
|
||||
"schedule": [
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793296800000,
|
||||
"duration_ms": 720000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk\"1",
|
||||
"start_ms": 1793297520000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-101",
|
||||
"start_ms": 1793297610000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"duration_ms": 120000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793298600000,
|
||||
"duration_ms": 930500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"duration_ms": 60000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "tk-7",
|
||||
"start_ms": 1793299590500,
|
||||
"duration_ms": 809500,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793300400000,
|
||||
"duration_ms": 840000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"duration_ms": 90000,
|
||||
"kind": "ad"
|
||||
},
|
||||
{
|
||||
"asset_id": "hl-102",
|
||||
"start_ms": 1793301330000,
|
||||
"duration_ms": 870000,
|
||||
"kind": "program"
|
||||
},
|
||||
{
|
||||
"asset_id": "nf-1",
|
||||
"start_ms": 1793302200000,
|
||||
"duration_ms": 1800000,
|
||||
"kind": "program"
|
||||
}
|
||||
],
|
||||
"window_start_ms": 1793296800000,
|
||||
"window_end_ms": 1793300400000,
|
||||
"aired": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6
|
||||
],
|
||||
"work_schedule_ops": 11,
|
||||
"joules_micro": 0,
|
||||
"energy_provenance": null,
|
||||
"grant": {
|
||||
"grant_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"capability": "video.channel",
|
||||
"joule_ceiling_micro": 18446744073709551615,
|
||||
"funds_ceiling": null
|
||||
},
|
||||
"parent_receipt_hash": null,
|
||||
"signer_pubkey": "da02a7371f5420e301ec0399395e8a1046850495872694b42b6d65ad38d2e16a",
|
||||
"signer_id": "sample-operator",
|
||||
"sig": "e46eb54cfb9875b8ac81ad19bf0902021468b44589d522bc5e168e7337c0332b371c71a197ef2ebbf8bdbf8d7f66c851b29172f2169812bbfdcba12956d0c900"
|
||||
}
|
||||
22
wai/live-conformance/channels/quoted.cues.json
Normal file
22
wai/live-conformance/channels/quoted.cues.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"break": "brk\"1",
|
||||
"start_ms": 1793297520000,
|
||||
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
|
||||
},
|
||||
{
|
||||
"break": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
|
||||
},
|
||||
{
|
||||
"break": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
|
||||
}
|
||||
]
|
||||
22
wai/live-conformance/channels/sample.cues.json
Normal file
22
wai/live-conformance/channels/sample.cues.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[
|
||||
{
|
||||
"break": "brk-1",
|
||||
"start_ms": 1793297520000,
|
||||
"payload": "04e9030000905f0100012800fc3025000000000000fffff01405000003e97feffe03dcc500fe007b98a00001000000008f389a15"
|
||||
},
|
||||
{
|
||||
"break": "brk-2",
|
||||
"start_ms": 1793298480000,
|
||||
"payload": "041733d0f9c0d40100011900fc3016000000000000fffff00506fe090321000000f83beb5f"
|
||||
},
|
||||
{
|
||||
"break": "brk-3",
|
||||
"start_ms": 1793299530500,
|
||||
"payload": "04eb03000060ea0000012800fc3025000000000000fffff01405000003eb7feffe0ea5c568fe005265c000010000000045b18e8a"
|
||||
},
|
||||
{
|
||||
"break": "brk-4",
|
||||
"start_ms": 1793301240000,
|
||||
"payload": "04ec030000905f0100012800fc3025000000000000fffff01405000003ec7feffe17d16980fe007b98a0000100000000959f6b5f"
|
||||
}
|
||||
]
|
||||
57
wai/live-conformance/channels/sample.fractional.m3u8
Normal file
57
wai/live-conformance/channels/sample.fractional.m3u8
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:7
|
||||
#EXT-X-MEDIA-SEQUENCE:700
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:53.994Z
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
|
||||
#EXTINF:6.006,
|
||||
seg-700.ts
|
||||
#EXT-X-CUE-OUT:90.000
|
||||
#EXTINF:6.006,
|
||||
seg-701.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=6.006,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-702.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=12.012,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-703.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=18.018,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-704.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=24.024,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-705.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.030,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-706.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=36.036,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-707.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=42.042,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-708.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=48.048,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-709.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=54.054,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-710.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.060,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-711.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=66.066,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-712.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=72.072,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-713.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=78.078,Duration=90.000
|
||||
#EXTINF:6.006,
|
||||
seg-714.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=84.084,Duration=90.000
|
||||
#EXTINF:5.916,
|
||||
seg-715.ts
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:6.006,
|
||||
seg-716.ts
|
||||
39
wai/live-conformance/channels/sample.inside.m3u8
Normal file
39
wai/live-conformance/channels/sample.inside.m3u8
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:171
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:28:30.000Z
|
||||
#EXT-X-DATERANGE:ID="brk-2@1793298480000",START-DATE="2026-10-29T18:28:00.000Z",PLANNED-DURATION=120.000,SCTE35-OUT=0xFC3016000000000000FFFFF00506FE090321000000F83BEB5F
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-171.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-172.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-173.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-174.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-175.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-176.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=90.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-177.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=100.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-178.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=110.000,Duration=120.000
|
||||
#EXTINF:10.000,
|
||||
seg-179.ts
|
||||
#EXT-X-DATERANGE:ID="brk-2@1793298480000",START-DATE="2026-10-29T18:28:00.000Z",DURATION=120.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:10.000,
|
||||
seg-180.ts
|
||||
#EXTINF:10.000,
|
||||
seg-181.ts
|
||||
7
wai/live-conformance/channels/sample.last-sequence.m3u8
Normal file
7
wai/live-conformance/channels/sample.last-sequence.m3u8
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:9007199254740991
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
|
||||
#EXTINF:10.000,
|
||||
seg-9007199254740991.ts
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:4294968
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-30T18:00:00.000Z
|
||||
#EXTINF:4294967.295,
|
||||
seg-0.ts
|
||||
7
wai/live-conformance/channels/sample.longest-uri.m3u8
Normal file
7
wai/live-conformance/channels/sample.longest-uri.m3u8
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:120
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
|
||||
#EXTINF:10.000,
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.ts
|
||||
41
wai/live-conformance/channels/sample.no-section.m3u8
Normal file
41
wai/live-conformance/channels/sample.no-section.m3u8
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:70
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:40.000Z
|
||||
#EXTINF:10.000,
|
||||
seg-70.ts
|
||||
#EXTINF:10.000,
|
||||
seg-71.ts
|
||||
#EXT-X-CUE-OUT:90.000
|
||||
#EXTINF:10.000,
|
||||
seg-72.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-73.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=20.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-74.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-75.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-76.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-77.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-78.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-79.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-80.ts
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:10.000,
|
||||
seg-81.ts
|
||||
#EXTINF:10.000,
|
||||
seg-82.ts
|
||||
11
wai/live-conformance/channels/sample.none.m3u8
Normal file
11
wai/live-conformance/channels/sample.none.m3u8
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:120
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:20:00.000Z
|
||||
#EXTINF:10.000,
|
||||
seg-120.ts
|
||||
#EXTINF:10.000,
|
||||
seg-121.ts
|
||||
#EXTINF:10.000,
|
||||
seg-122.ts
|
||||
45
wai/live-conformance/channels/sample.offset.m3u8
Normal file
45
wai/live-conformance/channels/sample.offset.m3u8
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:452
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:45:24.500Z
|
||||
#EXT-X-DATERANGE:ID="brk-3@1793299530500",START-DATE="2026-10-29T18:45:30.500Z",PLANNED-DURATION=60.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003EB7FEFFE0EA5C568FE005265C000010000000045B18E8A
|
||||
#EXTINF:6.000,
|
||||
seg-452.ts
|
||||
#EXT-X-CUE-OUT:60.000
|
||||
#EXTINF:6.000,
|
||||
seg-453.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=6.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-454.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=12.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-455.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=18.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-456.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=24.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-457.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-458.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=36.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-459.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=42.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-460.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=48.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-461.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=54.000,Duration=60.000
|
||||
#EXTINF:6.000,
|
||||
seg-462.ts
|
||||
#EXT-X-DATERANGE:ID="brk-3@1793299530500",START-DATE="2026-10-29T18:45:30.500Z",DURATION=60.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:6.000,
|
||||
seg-463.ts
|
||||
#EXTINF:6.000,
|
||||
seg-464.ts
|
||||
#EXT-X-ENDLIST
|
||||
43
wai/live-conformance/channels/sample.out-in.m3u8
Normal file
43
wai/live-conformance/channels/sample.out-in.m3u8
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:70
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:11:40.000Z
|
||||
#EXTINF:10.000,
|
||||
seg-70.ts
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
|
||||
#EXTINF:10.000,
|
||||
seg-71.ts
|
||||
#EXT-X-CUE-OUT:90.000
|
||||
#EXTINF:10.000,
|
||||
seg-72.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-73.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=20.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-74.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=30.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-75.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-76.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=50.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-77.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=60.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-78.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=70.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-79.ts
|
||||
#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000
|
||||
#EXTINF:10.000,
|
||||
seg-80.ts
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:10.000,
|
||||
seg-81.ts
|
||||
#EXTINF:10.000,
|
||||
seg-82.ts
|
||||
12
wai/live-conformance/channels/sample.return.m3u8
Normal file
12
wai/live-conformance/channels/sample.return.m3u8
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:10
|
||||
#EXT-X-MEDIA-SEQUENCE:81
|
||||
#EXT-X-PROGRAM-DATE-TIME:2026-10-29T18:13:30.000Z
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",PLANNED-DURATION=90.000,SCTE35-OUT=0xFC3025000000000000FFFFF01405000003E97FEFFE03DCC500FE007B98A00001000000008F389A15
|
||||
#EXT-X-DATERANGE:ID="brk-1@1793297520000",START-DATE="2026-10-29T18:12:00.000Z",DURATION=90.000
|
||||
#EXT-X-CUE-IN
|
||||
#EXTINF:10.000,
|
||||
seg-81.ts
|
||||
#EXTINF:10.000,
|
||||
seg-82.ts
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -27,7 +27,10 @@ else is here:
|
|||
inclusion proof (§6.1, `inclusion_proof`), which the receipt ledger's verifier checks a job
|
||||
source's outputs with; and each third-party hop record (§6.2): its members and forms, no key,
|
||||
header or URL query in it, its signature under its own domain, and its binding to its attempt,
|
||||
its candidate and what its hop's receipt names.
|
||||
its candidate and what its hop's receipt names;
|
||||
- each delivery-decision record (§6.3): its members and forms, its signature under its own
|
||||
domain, its binding to its delivery's documents, the routing of §4 over them recomputed, the
|
||||
route and the key each other route was passed over by, its parent and its chain.
|
||||
|
||||
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
|
||||
§8 and every code it reuses appears in it, that §8 names every reason it raises, and that no
|
||||
|
|
@ -650,8 +653,13 @@ def verify(doc):
|
|||
|
||||
def verify_steps(doc):
|
||||
has_third = isinstance(doc, dict) and "third_party" in doc
|
||||
members = ["request", "pricebook", "policy", "record", "receipts", "keys"] + (["third_party"] if has_third else [])
|
||||
has_deliveries = isinstance(doc, dict) and "deliveries" in doc
|
||||
members = ["request", "pricebook", "policy", "record", "receipts", "keys"] + (["third_party"] if has_third else []) \
|
||||
+ (["deliveries"] if has_deliveries else [])
|
||||
if not exactly(doc, members) \
|
||||
or (has_deliveries and not (isinstance(doc["deliveries"], list) and all(
|
||||
exactly(x, ["request", "pricebook", "policy", "decision"]) and all(isinstance(x[k], str) for k in x)
|
||||
for x in doc["deliveries"]))) \
|
||||
or (has_third and not (isinstance(doc["third_party"], list) and all(isinstance(x, str) for x in doc["third_party"]))) \
|
||||
or (has_third and doc["receipts"] is None) \
|
||||
or not all(isinstance(doc[k], str) for k in ("request", "pricebook", "policy", "record")) \
|
||||
|
|
@ -736,6 +744,8 @@ def verify_steps(doc):
|
|||
unmetered = hop_receipts(rec, doc["receipts"], doc["keys"])
|
||||
# 9. The third-party hop records.
|
||||
third_party = third_party_records(rec, doc["third_party"], doc["receipts"]) if has_third else None
|
||||
# 10. The delivery decisions.
|
||||
deliveries = delivery_decisions(rec, doc["deliveries"]) if has_deliveries else None
|
||||
served_attempt = atts[-1] if last_served else None
|
||||
billed_hop = next((a["hop"] for a in atts if a["billed"]), None)
|
||||
if deadline is None or served_attempt is None:
|
||||
|
|
@ -761,6 +771,8 @@ def verify_steps(doc):
|
|||
}
|
||||
if third_party is not None:
|
||||
accepted["third_party"] = third_party
|
||||
if deliveries is not None:
|
||||
accepted["deliveries"] = deliveries
|
||||
return accepted
|
||||
|
||||
|
||||
|
|
@ -1059,6 +1071,155 @@ def third_party_records(rec, texts, receipt_texts):
|
|||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §6.3 a delivery's decision
|
||||
|
||||
DELIVERY_DOMAIN = b"wai:route-delivery-decision\x01"
|
||||
DELIVERY = ["kind", "version", "job", "parent", "seq", "prev", "objects", "delivery", "operation", "capability", "fallback",
|
||||
"quantities", "request", "policy", "pricebook", "candidates", "order", "decision", "route", "passed", "decided",
|
||||
"signer", "sig"]
|
||||
PASSED_KEYS = ["targets", "price", "ttfb", "energy", "quality", "endpoint"] # §4.4
|
||||
|
||||
|
||||
def delivery_form(d):
|
||||
"""§6.3: the members, each of its own form. Relations between them are step 10's."""
|
||||
if not exactly(d, DELIVERY):
|
||||
return False
|
||||
o = d["objects"]
|
||||
return (d["version"] == 1 and is_token(d["job"]) and is_hex(d["parent"], 64) and is_int(d["seq"], 1)
|
||||
and null_or(d["prev"], lambda x: is_hex(x, 64))
|
||||
and exactly(o, ["count", "root"]) and is_int(o["count"], 1) and is_hex(o["root"], 64)
|
||||
and is_token(d["delivery"]) and isinstance(d["operation"], str) and OPERATION.match(d["operation"]) is not None
|
||||
and is_cap(d["capability"]) and null_or(d["fallback"], is_cap) and quantities_ok(d["quantities"])
|
||||
and is_hex(d["request"], 64)
|
||||
and exactly(d["policy"], ["name", "version", "digest"]) and is_token(d["policy"]["name"])
|
||||
and is_int(d["policy"]["version"], 1) and is_hex(d["policy"]["digest"], 64)
|
||||
and exactly(d["pricebook"], ["digest", "currency", "issued"]) and is_hex(d["pricebook"]["digest"], 64)
|
||||
and isinstance(d["pricebook"]["currency"], str) and CURRENCY.match(d["pricebook"]["currency"]) is not None
|
||||
and is_int(d["pricebook"]["issued"])
|
||||
and isinstance(d["candidates"], list) and len(d["candidates"]) <= 1024 and all(candidate_form(c, 2) for c in d["candidates"])
|
||||
and isinstance(d["order"], list) and len(d["order"]) <= 1024 and all(is_token(x) for x in d["order"])
|
||||
and exactly(d["decision"], ["decided_by", "runner_up"]) and d["decision"]["decided_by"] in DECIDED_BY
|
||||
and null_or(d["decision"]["runner_up"], is_token)
|
||||
and null_or(d["route"], is_token)
|
||||
and isinstance(d["passed"], list) and len(d["passed"]) <= 1023
|
||||
and all(exactly(x, ["endpoint", "key"]) and is_token(x["endpoint"]) and x["key"] in PASSED_KEYS for x in d["passed"])
|
||||
and is_int(d["decided"]) and is_hex(d["signer"], 64) and is_hex(d["sig"], 128))
|
||||
|
||||
|
||||
def passed_over(req, pol, cands):
|
||||
"""§6.3 `passed`: each survivor after the first, in the order, with the first key of the key
|
||||
list on which it and the first differ."""
|
||||
survivors = sorted((c for c in cands if c["rank"] is not None), key=lambda c: c["rank"])
|
||||
if not survivors:
|
||||
return []
|
||||
out = []
|
||||
for c in survivors[1:]:
|
||||
key = next(k for k in key_list(req, pol) if compare(req, k, survivors[0], c))
|
||||
out.append({"endpoint": c["endpoint"], "key": key})
|
||||
return out
|
||||
|
||||
|
||||
def delivered_by(rec):
|
||||
"""What the record's served attempt delivered: (count, root, end), or None for a record of
|
||||
version 1 and one no hop served."""
|
||||
atts = rec["attempts"]
|
||||
if not atts or atts[-1]["outcome"] != "served":
|
||||
return None
|
||||
a = atts[-1]
|
||||
if "delivered" in a:
|
||||
return a["delivered"]["count"], a["delivered"]["root"], a["end"]
|
||||
if "outputs" in a:
|
||||
return len(a["outputs"]), commitment(a["outputs"]), a["end"]
|
||||
return None
|
||||
|
||||
|
||||
def delivery_decisions(rec, entries):
|
||||
"""§7.2 step 10: a job's decisions, from the first and in order."""
|
||||
parent = delivered_by(rec)
|
||||
out, before, seen = [], None, set()
|
||||
for i, t in enumerate(entries):
|
||||
place = ["deliveries", i]
|
||||
req = read(t["request"], request_ok, "request_invalid", place + ["request"])
|
||||
book = read(t["pricebook"], pricebook_ok, "pricebook_invalid", place + ["pricebook"])
|
||||
pol = read(t["policy"], policy_ok, "policy_invalid", place + ["policy"])
|
||||
d = read_document(t["decision"])
|
||||
at = lambda m: place + ["decision", m] # noqa: E731
|
||||
if not isinstance(d, dict) or d.get("kind") != "wai-route-delivery-decision":
|
||||
raise Refuse("malformed", place + ["decision"])
|
||||
if on("dd-version") and is_int(d.get("version")) and d["version"] != 1:
|
||||
raise Refuse("unknown_version", at("version"))
|
||||
if on("dd-form") and not delivery_form(d):
|
||||
raise Refuse("delivery_invalid", place + ["decision"])
|
||||
if on("dd-signature"):
|
||||
body = {k: v for k, v in d.items() if k != "sig"}
|
||||
bad = ed_verify(bytes.fromhex(d["signer"]), DELIVERY_DOMAIN + canonical(body).encode("utf-8"), bytes.fromhex(d["sig"]))
|
||||
if bad:
|
||||
raise Refuse(bad, at("sig"))
|
||||
# Its documents.
|
||||
for m, want in (("delivery", req["job"]), ("operation", req["operation"]), ("capability", req["capability"]),
|
||||
("fallback", req["fallback"]), ("quantities", req["quantities"]), ("request", digest(req))):
|
||||
if on("dd-request-" + m) and not same(d[m], want):
|
||||
raise Refuse("request_mismatch", at(m))
|
||||
for m, want in (("name", pol["name"]), ("version", pol["version"]), ("digest", digest(pol))):
|
||||
if on("dd-policy-" + m) and not same(d["policy"][m], want):
|
||||
raise Refuse("policy_mismatch", at("policy"))
|
||||
if on("dd-pricebook-version") and book_version(book) != 2:
|
||||
raise Refuse("pricebook_mismatch", at("pricebook"))
|
||||
for m, want in (("digest", digest(book)), ("currency", book["currency"]), ("issued", book["issued"])):
|
||||
if on("dd-pricebook-" + m) and not same(d["pricebook"][m], want):
|
||||
raise Refuse("pricebook_mismatch", at("pricebook"))
|
||||
# §4 over them.
|
||||
cands, order, decision, _ = route(req, book, pol)
|
||||
stated = d["candidates"]
|
||||
for j in range(max(len(stated), len(cands))):
|
||||
here = place + ["decision", "candidates", j]
|
||||
if j >= len(stated) or j >= len(cands):
|
||||
raise Refuse("candidate_mismatch", here)
|
||||
s_, c = stated[j], cands[j]
|
||||
if on("dd-candidates") and not same({k: v for k, v in s_.items() if k not in ("removed", "rank")},
|
||||
{k: v for k, v in c.items() if k not in ("removed", "rank")}):
|
||||
raise Refuse("candidate_mismatch", here)
|
||||
if on("dd-removals") and not same(s_["removed"], c["removed"]):
|
||||
raise Refuse("removal_mismatch", here)
|
||||
if on("dd-ranks") and not same(s_["rank"], c["rank"]):
|
||||
raise Refuse("rank_mismatch", here)
|
||||
if on("dd-order") and not same(d["order"], order):
|
||||
raise Refuse("rank_mismatch", at("order"))
|
||||
if on("dd-decision") and not same(d["decision"], decision):
|
||||
raise Refuse("decision_mismatch", at("decision"))
|
||||
# Its route.
|
||||
route_ = order[0] if order else None
|
||||
if on("dd-route") and not same(d["route"], route_):
|
||||
raise Refuse("delivery_route_mismatch", at("route"))
|
||||
passed = passed_over(req, pol, cands)
|
||||
if on("dd-passed") and not same(d["passed"], passed):
|
||||
raise Refuse("delivery_route_mismatch", at("passed"))
|
||||
# Its parent.
|
||||
for m in ("signer", "job"):
|
||||
if on("dd-parent-" + m) and d[m] != rec[m]:
|
||||
raise Refuse("delivery_parent_mismatch", at(m))
|
||||
if on("dd-parent") and d["parent"] != digest(rec):
|
||||
raise Refuse("delivery_parent_mismatch", at("parent"))
|
||||
if parent is None:
|
||||
raise Refuse("delivery_parent_mismatch", at("objects"))
|
||||
count, root, served_end = parent
|
||||
if on("dd-objects") and not same(d["objects"], {"count": count, "root": root}):
|
||||
raise Refuse("delivery_parent_mismatch", at("objects"))
|
||||
# Its chain.
|
||||
if on("dd-seq") and d["seq"] != i + 1:
|
||||
raise Refuse("delivery_chain_broken", at("seq"))
|
||||
if on("dd-prev") and d["prev"] != (before[0] if before else None):
|
||||
raise Refuse("delivery_chain_broken", at("prev"))
|
||||
if on("dd-decided") and (d["decided"] < served_end or (before is not None and d["decided"] < before[1])):
|
||||
raise Refuse("delivery_chain_broken", at("decided"))
|
||||
if on("dd-once") and req["job"] in seen:
|
||||
raise Refuse("delivery_chain_broken", at("delivery"))
|
||||
seen.add(req["job"])
|
||||
before = (digest(d), d["decided"])
|
||||
out.append({"seq": i + 1, "delivery": req["job"], "digest": digest(d), "route": route_, "decision": decision, "passed": passed})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the corpus
|
||||
|
||||
def check(doc, quiet=False):
|
||||
|
|
@ -1144,6 +1305,16 @@ def mutations(doc):
|
|||
copy_with("its stated third-party digest changed", k, lambda x: x["outcome"]["third_party"][0].__setitem__("digest", "00" * 32))
|
||||
copy_with("a record's body named by another digest", k, lambda x: x["document"]["third_party"].__setitem__(
|
||||
0, x["document"]["third_party"][0].replace('"bytes":', '"bytes":1', 1)))
|
||||
if "deliveries" in o and o["deliveries"]:
|
||||
copy_with("its stated route changed", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("route", "relay.us" if o["deliveries"][0]["route"] != "relay.us" else None))
|
||||
copy_with("its stated passed routes dropped", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("passed", [] if o["deliveries"][0]["passed"] else [{"endpoint": "x", "key": "price"}]))
|
||||
copy_with("its stated decision's digest changed", k, lambda x: x["outcome"]["deliveries"][0].__setitem__("digest", "00" * 32))
|
||||
copy_with("its stated delivery's seq changed", k, lambda x: x["outcome"]["deliveries"][-1].__setitem__("seq", 7))
|
||||
copy_with("its last decision dropped", k, lambda x: x["document"]["deliveries"].pop())
|
||||
copy_with("a decision's text another", k, lambda x: x["document"]["deliveries"][0].__setitem__(
|
||||
"decision", x["document"]["deliveries"][0]["decision"].replace('"decided":', '"decided":1', 1)))
|
||||
if "deliveries" in o and not o["deliveries"]:
|
||||
copy_with("its stated deliveries dropped", k, lambda x: x["outcome"].pop("deliveries"))
|
||||
copy_with("its stated deadline changed", k, lambda x: x["outcome"].__setitem__("deadline", "met" if o["deadline"] != "met" else "missed"))
|
||||
copy_with("its stated order reversed", k, lambda x: x["outcome"].__setitem__("order", list(reversed(o["order"])) + ["x"]))
|
||||
copy_with("its stated decision changed", k, lambda x: x["outcome"]["decision"].__setitem__("decided_by", "price" if o["decision"]["decided_by"] != "price" else "ttfb"))
|
||||
|
|
@ -1169,7 +1340,13 @@ CHECKS = ["request_invalid", "pricebook_invalid", "policy_invalid", "version", "
|
|||
"tp-most-exchanges", "tp-signer", "tp-job", "tp-hop", "tp-one-a-hop", "tp-endpoint",
|
||||
"tp-receipt", "tp-outcome", "tp-error", "tp-provider", "tp-unit", "tp-start", "tp-end", "tp-root", "tp-count",
|
||||
"limit-policy", "limit-unit", "limit-region", "limit-residency", "limit-training", "limit-retention", "limit-ga",
|
||||
"limit-consent", "limit-unit-price", "limit-spend"]
|
||||
"limit-consent", "limit-unit-price", "limit-spend",
|
||||
"dd-version", "dd-form", "dd-signature", "dd-request-delivery", "dd-request-operation", "dd-request-capability",
|
||||
"dd-request-fallback", "dd-request-quantities", "dd-request-request", "dd-policy-name", "dd-policy-version",
|
||||
"dd-policy-digest", "dd-pricebook-version", "dd-pricebook-digest", "dd-pricebook-currency", "dd-pricebook-issued",
|
||||
"dd-candidates", "dd-removals",
|
||||
"dd-ranks", "dd-order", "dd-decision", "dd-route", "dd-passed", "dd-parent-signer", "dd-parent-job", "dd-parent",
|
||||
"dd-objects", "dd-seq", "dd-prev", "dd-decided", "dd-once"]
|
||||
|
||||
|
||||
def main(corpus_dir):
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ for the control that pairs one with a price book of version 1: the served hop's
|
|||
by their count and root, to which its receipt is held, 5,000 of them in one record the size of
|
||||
the others. Then those of the third-party hop record (§6.2), whose names begin `third_party_`:
|
||||
each document carries the records beside the receipts, under route records of versions 2 and
|
||||
3, with each rule of §6.2 broken once.
|
||||
3, with each rule of §6.2 broken once. Then those of the delivery-decision record (§6.3), whose
|
||||
names begin `delivery_`: each document carries, in `deliveries`, a job's decisions with each
|
||||
delivery's own request, price book of version 2 and policy, under route records of versions 2
|
||||
and 3. The routes are priced per gigabyte for the operation `http.export`, and each rule of §6.3
|
||||
is broken once.
|
||||
|
||||
```sh
|
||||
# wai-rs: regenerates byte for byte, and every vector gets its outcome
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -4039,6 +4039,13 @@
|
|||
"section": "§6",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai-route-delivery-decision",
|
||||
"kind": "document-kind",
|
||||
"document": "extensions/route-record.md",
|
||||
"section": "§6.3",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "wai-route-policy",
|
||||
"kind": "document-kind",
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@
|
|||
//! description, artwork by content hash, where it may be shown and its rights holder, and where
|
||||
//! each programme of the guide begins, bound to the schedule by its root. The **guide** — an
|
||||
//! XMLTV document ([`xmltv_guide`]) or a now/next document ([`now_next`]) — is rendered from the
|
||||
//! receipt and the lineup and names both, so [`verify`] recomputes it and compares.
|
||||
//! receipt and the lineup and names both, so [`verify`] recomputes it and compares. The cue
|
||||
//! tags of a Media Playlist of the channel ([`cue_tags`]) are rendered from the receipt and the
|
||||
//! import's cues, so [`cue_tags::verify_playlist`] recomputes them and compares too.
|
||||
//!
|
||||
//! Every function here is a pure function of the bytes it is given: no I/O, no clock and no
|
||||
//! floating point. The corpus of §11 is [`corpus`].
|
||||
|
||||
pub mod corpus;
|
||||
pub mod cue_tags;
|
||||
pub mod import;
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
|
@ -86,6 +89,12 @@ pub enum Reason {
|
|||
GuideSourceMismatch,
|
||||
GuideEntryUnscheduled,
|
||||
GuideMismatch,
|
||||
CuesMalformed,
|
||||
CuesMismatch,
|
||||
PlaylistMalformed,
|
||||
CueUnaligned,
|
||||
CueUnwritable,
|
||||
PlaylistMismatch,
|
||||
BadKey,
|
||||
BadSignature,
|
||||
}
|
||||
|
|
@ -123,6 +132,12 @@ impl Reason {
|
|||
Reason::GuideSourceMismatch,
|
||||
Reason::GuideEntryUnscheduled,
|
||||
Reason::GuideMismatch,
|
||||
Reason::CuesMalformed,
|
||||
Reason::CuesMismatch,
|
||||
Reason::PlaylistMalformed,
|
||||
Reason::CueUnaligned,
|
||||
Reason::CueUnwritable,
|
||||
Reason::PlaylistMismatch,
|
||||
];
|
||||
/// live-events' codes a verification also gives (§10).
|
||||
pub const REUSED: &'static [Reason] = &[Reason::BadKey, Reason::BadSignature];
|
||||
|
|
@ -160,6 +175,12 @@ impl Reason {
|
|||
Reason::GuideSourceMismatch => "guide_source_mismatch",
|
||||
Reason::GuideEntryUnscheduled => "guide_entry_unscheduled",
|
||||
Reason::GuideMismatch => "guide_mismatch",
|
||||
Reason::CuesMalformed => "cues_malformed",
|
||||
Reason::CuesMismatch => "cues_mismatch",
|
||||
Reason::PlaylistMalformed => "playlist_malformed",
|
||||
Reason::CueUnaligned => "cue_unaligned",
|
||||
Reason::CueUnwritable => "cue_unwritable",
|
||||
Reason::PlaylistMismatch => "playlist_mismatch",
|
||||
Reason::BadKey => "bad_key",
|
||||
Reason::BadSignature => "bad_signature",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ use ed25519_dalek::{Signer, SigningKey};
|
|||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::import::{import, Feeds, Imported};
|
||||
use super::cue_tags::{self, Segment, Window, WINDOW_ENDS_BEFORE};
|
||||
use super::import::{break_cue, import, Cue, Feeds, Imported};
|
||||
use super::{hex, now_next, receipt_text, verification_json, verify as verify_all, xmltv_guide, Lineup, Reason};
|
||||
|
||||
/// 2026-10-29T18:00:00Z, the sample's first programme.
|
||||
|
|
@ -543,6 +544,192 @@ pub fn run_verification(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<
|
|||
Ok(verification_json(&verify_all(receipt, &lineup, guide)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// The Media Playlist's cue tags (§7.4, §8.4)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// A window of segments `seg-<n>.ts`, numbered from its media sequence number.
|
||||
fn window(seq: u64, start: u64, durations: &[u64], ended: bool) -> Window {
|
||||
let segments = durations.iter().enumerate().map(|(k, &d)| Segment { uri: format!("seg-{}.ts", seq + k as u64), duration_ms: d }).collect();
|
||||
Window { media_sequence: seq, start_ms: start, segments, ended }
|
||||
}
|
||||
|
||||
/// A window of one ten-second segment whose URI is `uri`.
|
||||
fn one_uri(seq: u64, start: u64, uri: &str) -> Window {
|
||||
Window { media_sequence: seq, start_ms: start, segments: vec![Segment { uri: uri.into(), duration_ms: 10_000 }], ended: false }
|
||||
}
|
||||
|
||||
/// brk-1 in segments of 6.006 seconds: the last segment before it, fourteen in it and one of
|
||||
/// 5.916 seconds that ends at its return, then the return.
|
||||
fn fractional() -> Vec<u64> {
|
||||
let mut d = vec![6_006u64; 15];
|
||||
d.push(5_916);
|
||||
d.push(6_006);
|
||||
d
|
||||
}
|
||||
|
||||
/// The far channel's receipt and cues: a programme that ends at the last instant a date of four
|
||||
/// digits of year writes (9999-12-31T23:59:59.999Z), far-1, a break of one millisecond from that
|
||||
/// instant, and far-2, of 30.001 seconds from 10000-01-01T00:00:00Z; each break with brk-2's
|
||||
/// section.
|
||||
fn far(grant: &crate::quantum_ops::GrantRef) -> (crate::wai_video_channel::ChannelReceipt, Vec<Cue>) {
|
||||
use crate::wai_video_channel::{ChannelReceipt, EntryKind, ScheduleEntry};
|
||||
let e = WINDOW_ENDS_BEFORE;
|
||||
let section = breaks()[1].3.clone();
|
||||
let entry = |id: &str, start_ms: u64, duration_ms: u64, kind: EntryKind| ScheduleEntry { asset_id: id.into(), start_ms, duration_ms, kind };
|
||||
let schedule = vec![entry("far-prog", e - 60_001, 60_000, EntryKind::Program), entry("far-1", e - 1, 1, EntryKind::Ad), entry("far-2", e, 30_001, EntryKind::Ad)];
|
||||
let r = ChannelReceipt::seal(&operator(), "sample-operator", "far", schedule, e - 60_001, e + 30_001, 0, None, grant.clone(), None);
|
||||
let cues = [("far-1", e - 1, 1), ("far-2", e, 30_001)].iter().map(|&(id, start_ms, d)| Cue { break_id: id.into(), start_ms, payload: break_cue(id, d, Some(§ion)) }).collect();
|
||||
(r, cues)
|
||||
}
|
||||
|
||||
/// The same receipt and cues with brk-1's id `from` named `to`, signed again.
|
||||
fn renamed(r: &crate::wai_video_channel::ChannelReceipt, cues: &[Cue], from: &str, to: &str) -> (crate::wai_video_channel::ChannelReceipt, Vec<Cue>) {
|
||||
let mut schedule = r.schedule.clone();
|
||||
let e = schedule.iter_mut().find(|e| e.asset_id == from).expect("the break");
|
||||
e.asset_id = to.into();
|
||||
let (duration, section) = (e.duration_ms, breaks()[0].3.clone());
|
||||
let sealed = crate::wai_video_channel::ChannelReceipt::seal(
|
||||
&operator(),
|
||||
r.signer_id.clone(),
|
||||
r.channel_id.clone(),
|
||||
schedule,
|
||||
r.window_start_ms,
|
||||
r.window_end_ms,
|
||||
r.joules_micro,
|
||||
r.energy_provenance.clone(),
|
||||
r.grant.clone(),
|
||||
r.parent_receipt_hash,
|
||||
);
|
||||
let cues = cues.iter().map(|c| if c.break_id == from { Cue { break_id: to.into(), payload: break_cue(to, duration, Some(§ion)), ..c.clone() } } else { c.clone() }).collect();
|
||||
(sealed, cues)
|
||||
}
|
||||
|
||||
/// A playlist vector: its name, what it shows, the receipt and cues files, the window, and the
|
||||
/// playlist file it renders or the refusal it was written for, with its index.
|
||||
type PlaylistVector = (&'static str, &'static str, &'static str, &'static str, Window, Result<&'static str, (Reason, Option<usize>)>);
|
||||
|
||||
/// Each playlist vector (§7.4).
|
||||
fn playlist_vectors() -> Vec<PlaylistVector> {
|
||||
use Reason::*;
|
||||
let (rc, cues) = ("sample.channel.json", "sample.cues.json");
|
||||
let ten = |n: usize| vec![10_000u64; n];
|
||||
let mut late_end = ten(10);
|
||||
late_end.push(15_000);
|
||||
let brk_1 = T0 + 12 * MIN;
|
||||
let day_after = T0 + 24 * 60 * MIN;
|
||||
let last = WINDOW_ENDS_BEFORE - 1;
|
||||
let (fr, fc) = ("far.channel.json", "far.cues.json");
|
||||
let longest = "a".repeat(2_045) + ".ts";
|
||||
vec![
|
||||
("out_in", "brk-1 (a splice_insert() section) from two segments before it to one after its return: its date range announced on the last segment before it, EXT-X-CUE-OUT on its first, EXT-X-CUE-OUT-CONT on each later one, EXT-X-CUE-IN and the range's end on its return", rc, cues, window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Ok("sample.out-in.m3u8")),
|
||||
("inside", "a live window that begins 30 seconds into brk-2 (a time_signal() section): the date range on its first segment, which carries EXT-X-CUE-OUT-CONT", rc, cues, window(171, T0 + 28 * MIN + 30_000, &ten(11), false), Ok("sample.inside.m3u8")),
|
||||
("offset", "brk-3, which begins half a second past a whole second, in six-second segments cut at its start and its end, the window ended", rc, cues, window(452, T0 + 45 * MIN + 24_500, &[6_000; 13], true), Ok("sample.offset.m3u8")),
|
||||
("return", "a window that begins at brk-1's return: EXT-X-CUE-IN on its first segment, and the whole date range before it", rc, cues, window(81, T0 + 13 * MIN + 30_000, &ten(2), false), Ok("sample.return.m3u8")),
|
||||
("none", "a window no break is in: no cue tag and no date range", rc, cues, window(120, T0 + 20 * MIN, &ten(3), false), Ok("sample.none.m3u8")),
|
||||
("no_section", "brk-1 from the JSON form with no section: the same receipt, and the cue tags with no date range", rc, "no-section.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Ok("sample.no-section.m3u8")),
|
||||
("unaligned_start", "segments cut five seconds off brk-1's start", rc, cues, window(70, T0 + 11 * MIN + 45_000, &ten(13), false), Err((CueUnaligned, Some(1)))),
|
||||
("unaligned_end", "a segment that runs across brk-1's return", rc, cues, window(70, T0 + 11 * MIN + 40_000, &late_end, false), Err((CueUnaligned, Some(10)))),
|
||||
("unwritable", "brk-1 named with a double quote, which no date range's ID can hold", "quoted.channel.json", "quoted.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
|
||||
("window_invalid", "a segment of no duration", rc, cues, window(70, T0 + 11 * MIN + 40_000, &[10_000, 0, 10_000], false), Err((PlaylistMalformed, None))),
|
||||
("fractional", "brk-1 in segments of 6.006 seconds and one of 5.916 that ends at its return: EXTINF and ElapsedTime to the millisecond, and a TARGETDURATION of 7, 6.006 rounded up", rc, cues, window(700, brk_1 - 6_006, &fractional(), false), Ok("sample.fractional.m3u8")),
|
||||
("longest_uri", "a segment URI of 2 048 bytes", rc, cues, one_uri(120, T0 + 20 * MIN, &longest), Ok("sample.longest-uri.m3u8")),
|
||||
("longest_segment", "a segment of 2^32 − 1 milliseconds, the day after the schedule: a TARGETDURATION of 4294968, its duration rounded up", rc, cues, window(0, day_after, &[u64::from(u32::MAX)], false), Ok("sample.longest-segment.m3u8")),
|
||||
("last_sequence", "a media sequence number of 2^53 − 1", rc, cues, window(9_007_199_254_740_991, T0 + 20 * MIN, &ten(1), false), Ok("sample.last-sequence.m3u8")),
|
||||
("last_instant", "a window that ends at 9999-12-31T23:59:59.999Z, where far-1 starts: far-1's date range on its last segment, dated in the year 9999, and far-2, a millisecond later, not in it", fr, fc, window(1_000, last - 10_000, &ten(1), false), Ok("far.m3u8")),
|
||||
("unwritable_line_feed", "brk-1 named with a line feed, which no quoted-string can hold", "line-feed.channel.json", "line-feed.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
|
||||
("unwritable_carriage_return", "brk-1 named with a carriage return, which no quoted-string can hold, in a receipt the operator signed (an import refuses the id)", "carriage-return.channel.json", "carriage-return.cues.json", window(70, T0 + 11 * MIN + 40_000, &ten(13), false), Err((CueUnwritable, Some(0)))),
|
||||
("past_the_last_instant", "the same window and a segment of a millisecond after it, which ends at 10000-01-01T00:00:00Z and would hold far-2's last segment before it, dated in the year 10000", fr, fc, window(1_000, last - 10_000, &[10_000, 1], false), Err((PlaylistMalformed, None))),
|
||||
("no_segment", "a window of no segment", rc, cues, window(120, T0 + 20 * MIN, &[], false), Err((PlaylistMalformed, None))),
|
||||
("uri_empty", "a segment URI of no byte", rc, cues, one_uri(120, T0 + 20 * MIN, ""), Err((PlaylistMalformed, None))),
|
||||
("uri_too_long", "a segment URI of 2 049 bytes", rc, cues, one_uri(120, T0 + 20 * MIN, &format!("a{longest}")), Err((PlaylistMalformed, None))),
|
||||
("uri_with_a_space", "a segment URI holding a space, below !", rc, cues, one_uri(120, T0 + 20 * MIN, "seg 120.ts"), Err((PlaylistMalformed, None))),
|
||||
("uri_not_ascii", "a segment URI holding a letter outside ASCII", rc, cues, one_uri(120, T0 + 20 * MIN, "seg-120-\u{e9}.ts"), Err((PlaylistMalformed, None))),
|
||||
("uri_as_a_tag", "a segment URI that begins with #, as a tag does", rc, cues, one_uri(120, T0 + 20 * MIN, "#seg-120.ts"), Err((PlaylistMalformed, None))),
|
||||
("segment_too_long", "a segment of 2^32 milliseconds, the day after the schedule", rc, cues, window(0, day_after, &[u64::from(u32::MAX) + 1], false), Err((PlaylistMalformed, None))),
|
||||
("sequence_too_large", "a media sequence number of 2^53", rc, cues, window(9_007_199_254_740_992, T0 + 20 * MIN, &ten(1), false), Err((PlaylistMalformed, None))),
|
||||
]
|
||||
}
|
||||
|
||||
/// The breaks a receipt file and a cues file give, bound (§8.4 step 5).
|
||||
fn bound(files: &BTreeMap<String, Vec<u8>>, receipt: &str, cues: &str) -> Result<Vec<cue_tags::Break>, String> {
|
||||
let r = super::read_receipt(files.get(receipt).ok_or("the receipt file")?).ok_or("the receipt reads")?;
|
||||
let c = cue_tags::read_cues(files.get(cues).ok_or("the cues file")?).ok_or("the cues read")?;
|
||||
cue_tags::bind_cues(&r, &c).map_err(|i| format!("cue {i} is not its break's"))
|
||||
}
|
||||
|
||||
/// Render a playlist vector over the files given.
|
||||
pub fn run_playlist(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Result<String, cue_tags::Unwritten>, String> {
|
||||
let breaks = bound(files, v["receipt"].as_str().ok_or("receipt")?, v["cues"].as_str().ok_or("cues")?)?;
|
||||
let w = cue_tags::window_of(&v["window"]).ok_or("the window")?;
|
||||
Ok(cue_tags::render(&breaks, &w))
|
||||
}
|
||||
|
||||
/// A playlist verification vector: its name, what it shows, the receipt, cues and playlist
|
||||
/// files, its edits.
|
||||
type PlaylistCheckVector = (&'static str, &'static str, &'static str, &'static str, &'static str, Value);
|
||||
|
||||
/// Each playlist verification vector (§8.4).
|
||||
fn playlist_check_vectors(sample: &Imported, cues_text: &str) -> Vec<PlaylistCheckVector> {
|
||||
let (rc, cues, pl) = ("sample.channel.json", "sample.cues.json", "sample.out-in.m3u8");
|
||||
let sig = hex(&sample.receipt.sig);
|
||||
let flipped = format!("{}{}", if sig.starts_with('0') { "1" } else { "0" }, &sig[1..]);
|
||||
let first = hex(&sample.cues[0].payload);
|
||||
// The first cue's duration (bytes 5 to 8 of its payload), one millisecond more; and its
|
||||
// section's splice_event_id (bytes 14 to 17 of the section, 26 to 29 of the payload), changed.
|
||||
let mut longer = sample.cues[0].payload.clone();
|
||||
longer[5] = longer[5].wrapping_add(1);
|
||||
let mut other_event = sample.cues[0].payload.clone();
|
||||
other_event[29] ^= 0x01;
|
||||
let last = cues_text.rfind(",\n {").expect("more than one cue");
|
||||
let tail = &cues_text[last..cues_text.len() - 2];
|
||||
vec![
|
||||
("playlist_out_in", "brk-1's playlist, verified", rc, cues, pl, json!([])),
|
||||
("playlist_inside", "a live window inside brk-2, verified", rc, cues, "sample.inside.m3u8", json!([])),
|
||||
("playlist_offset", "brk-3's ended window, verified", rc, cues, "sample.offset.m3u8", json!([])),
|
||||
("playlist_return", "a window from brk-1's return, verified", rc, cues, "sample.return.m3u8", json!([])),
|
||||
("playlist_none", "a window no break is in, verified", rc, cues, "sample.none.m3u8", json!([])),
|
||||
("playlist_no_section", "brk-1 with no section, verified against the same receipt and its own cues", rc, "no-section.cues.json", "sample.no-section.m3u8", json!([])),
|
||||
("playlist_fractional", "brk-1 in segments of 6.006 seconds, verified", rc, cues, "sample.fractional.m3u8", json!([])),
|
||||
("playlist_last_instant", "the far channel's window that ends at 9999-12-31T23:59:59.999Z, verified", "far.channel.json", "far.cues.json", "far.m3u8", json!([])),
|
||||
("pl_receipt_malformed", "a receipt missing a member", rc, cues, pl, json!([edit(rc, "\"signer_id\": \"sample-operator\"", "\"signer\": \"sample-operator\"")])),
|
||||
("pl_receipt_bad_signature", "a receipt whose signature was altered", rc, cues, pl, json!([edit(rc, &sig, &flipped)])),
|
||||
("pl_receipt_invalid", "a receipt, signed, whose program-as-run is not what its window gives", "aired-misreported.channel.json", cues, pl, json!([])),
|
||||
("cues_malformed", "a cue whose start is named start", rc, cues, pl, json!([edit(cues, "\"start_ms\": 1793297520000", "\"start\": 1793297520000")])),
|
||||
("cues_other_duration", "brk-1's cue a millisecond longer than its break", rc, cues, pl, json!([edit(cues, &first, &hex(&longer))])),
|
||||
("cues_other_event", "brk-1's section naming another splice_event_id than its cue", rc, cues, pl, json!([edit(cues, &first, &hex(&other_event))])),
|
||||
("cues_other_start", "brk-2's cue a second after its break's start", rc, cues, pl, json!([edit(cues, "\"start_ms\": 1793298480000", "\"start_ms\": 1793298481000")])),
|
||||
("cues_missing", "the last break's cue left out", rc, cues, pl, json!([edit(cues, tail, "")])),
|
||||
("cues_for_another_receipt", "the cues of brk-1 named with a double quote, against the sample's receipt", rc, "quoted.cues.json", pl, json!([])),
|
||||
("playlist_malformed", "a playlist with no media sequence number", rc, cues, pl, json!([edit(pl, "#EXT-X-MEDIA-SEQUENCE:70\n", "")])),
|
||||
("playlist_past_the_year_9999", "the far channel's playlist with its segment a millisecond longer, so that it ends at 10000-01-01T00:00:00Z", "far.channel.json", "far.cues.json", "far.m3u8", json!([edit("far.m3u8", "#EXTINF:10.000,", "#EXTINF:10.001,")])),
|
||||
("playlist_uri_as_a_tag", "a playlist whose segment URI begins with #", rc, cues, "sample.none.m3u8", json!([edit("sample.none.m3u8", "\nseg-120.ts\n", "\n#seg-120.ts\n")])),
|
||||
("playlist_unaligned", "a first segment five seconds longer, which moves every later one off brk-1's start", rc, cues, pl, json!([edit(pl, "#EXTINF:10.000,\nseg-70.ts", "#EXTINF:15.000,\nseg-70.ts")])),
|
||||
("playlist_unwritable", "brk-1's playlist against the receipt and cues that name it with a double quote", "quoted.channel.json", "quoted.cues.json", pl, json!([])),
|
||||
("playlist_other_duration", "EXT-X-CUE-OUT stating another duration than the break's", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT:90.000", "#EXT-X-CUE-OUT:60.000")])),
|
||||
("playlist_cont_dropped", "an EXT-X-CUE-OUT-CONT left out", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT-CONT:ElapsedTime=40.000,Duration=90.000\n", "")])),
|
||||
("playlist_cue_in_early", "EXT-X-CUE-IN a segment before the break's return", rc, cues, pl, json!([edit(pl, "#EXT-X-CUE-OUT-CONT:ElapsedTime=80.000,Duration=90.000", "#EXT-X-CUE-IN"), edit(pl, "#EXT-X-CUE-IN\n#EXTINF:10.000,\nseg-81.ts", "#EXTINF:10.000,\nseg-81.ts")])),
|
||||
("playlist_other_section", "a date range carrying another section than the cue's", rc, cues, pl, json!([edit(pl, "SCTE35-OUT=0xFC30", "SCTE35-OUT=0xFC31")])),
|
||||
("playlist_other_start", "a date range starting a second after the break", rc, cues, pl, json!([edit(pl, "START-DATE=\"2026-10-29T18:12:00.000Z\",PLANNED-DURATION", "START-DATE=\"2026-10-29T18:12:01.000Z\",PLANNED-DURATION")])),
|
||||
("playlist_no_range_end", "the date range's end left out", rc, cues, pl, json!([edit(pl, "#EXT-X-DATERANGE:ID=\"brk-1@1793297520000\",START-DATE=\"2026-10-29T18:12:00.000Z\",DURATION=90.000\n", "")])),
|
||||
]
|
||||
}
|
||||
|
||||
/// The files of a playlist verification vector, with its edits applied, and its facts.
|
||||
pub fn run_playlist_check(v: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Value, String> {
|
||||
let files = apply_edits(files, &v["edits"])?;
|
||||
let file = |k: &str| -> Result<&Vec<u8>, String> { files.get(v[k].as_str().ok_or(k)?).ok_or_else(|| format!("the {k} file")) };
|
||||
Ok(cue_tags::playlist_check_json(&cue_tags::verify_playlist(file("receipt")?, file("cues")?, file("playlist")?)))
|
||||
}
|
||||
|
||||
fn playlist_outcome(got: &Result<String, cue_tags::Unwritten>, file: Option<&str>) -> Value {
|
||||
match (got, file) {
|
||||
(Ok(_), Some(f)) => json!({"playlist": f}),
|
||||
(Err(u), _) => json!({"refused": u.reason.code(), "index": u.index}),
|
||||
(Ok(_), None) => json!({"playlist": null}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// generate, verify
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
@ -624,6 +811,71 @@ pub fn generate() -> Vec<(String, String)> {
|
|||
v["facts"] = facts;
|
||||
verifications.push(v);
|
||||
}
|
||||
// The cues the sample's import wrote; the same channel from the JSON form with brk-1's
|
||||
// section left out, which yields the same receipt and other cues; and with brk-1 named with a
|
||||
// double quote, which yields another receipt.
|
||||
let cues_text = serde_json::to_string_pretty(&cues_json(&sample)).expect("JSON") + "\n";
|
||||
files.push(("sample.cues.json".into(), cues_text.clone()));
|
||||
let json_inputs = &ivs[1].2;
|
||||
let section_1 = hex(&breaks()[0].3);
|
||||
let held = bytes_of(&files);
|
||||
let bare = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", &format!("\"scte35\": \"{section_1}\""), "\"scte35\": null")]}), &held)
|
||||
.expect("the JSON form")
|
||||
.expect("it imports");
|
||||
assert_eq!(receipt_text(&bare.receipt), receipt_text(&sample.receipt), "a section is not in the receipt");
|
||||
files.push(("no-section.cues.json".into(), serde_json::to_string_pretty(&cues_json(&bare)).expect("JSON") + "\n"));
|
||||
let quoted = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", "\"id\": \"brk-1\"", "\"id\": \"brk\\\"1\"")]}), &held)
|
||||
.expect("the JSON form")
|
||||
.expect("it imports");
|
||||
files.push(("quoted.channel.json".into(), receipt_text("ed.receipt)));
|
||||
files.push(("quoted.cues.json".into(), serde_json::to_string_pretty(&cues_json("ed)).expect("JSON") + "\n"));
|
||||
// brk-1 named with a line feed, which an import reads (§3.2); and with a carriage return,
|
||||
// which it does not, so the operator signs that receipt itself.
|
||||
let lf = run_import(&json!({"inputs": json_inputs, "edits": [edit("sample.json", "\"id\": \"brk-1\"", "\"id\": \"brk\\n1\"")]}), &held)
|
||||
.expect("the JSON form")
|
||||
.expect("it imports");
|
||||
let cues_file = |c: &[Cue]| serde_json::to_string_pretty(&super::import::cues_json(c)).expect("JSON") + "\n";
|
||||
files.push(("line-feed.channel.json".into(), receipt_text(&lf.receipt)));
|
||||
files.push(("line-feed.cues.json".into(), cues_file(&lf.cues)));
|
||||
let (cr, cr_cues) = renamed(&lf.receipt, &lf.cues, "brk\n1", "brk\r1");
|
||||
files.push(("carriage-return.channel.json".into(), receipt_text(&cr)));
|
||||
files.push(("carriage-return.cues.json".into(), cues_file(&cr_cues)));
|
||||
let (far_receipt, far_cues) = far(&sample.receipt.grant);
|
||||
files.push(("far.channel.json".into(), receipt_text(&far_receipt)));
|
||||
files.push(("far.cues.json".into(), cues_file(&far_cues)));
|
||||
|
||||
let held = bytes_of(&files);
|
||||
let mut playlists = Vec::new();
|
||||
for (name, about, receipt, cues, w, want) in playlist_vectors() {
|
||||
let mut v = json!({"name": name, "about": about, "receipt": receipt, "cues": cues, "window": cue_tags::window_json(&w)});
|
||||
let got = run_playlist(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
match (&got, &want) {
|
||||
(Ok(text), Ok(file)) => files.push((file.to_string(), text.clone())),
|
||||
(Err(u), Err((r, i))) => {
|
||||
assert_eq!((u.reason, u.index), (*r, *i), "{name}: refused {} at {:?}", u.reason, u.index);
|
||||
seen.insert(r.code());
|
||||
}
|
||||
(Err(u), Ok(_)) => panic!("{name}: refused {}", u.reason),
|
||||
(Ok(_), Err((r, _))) => panic!("{name}: rendered, written for {r}"),
|
||||
}
|
||||
v["outcome"] = playlist_outcome(&got, want.ok());
|
||||
playlists.push(v);
|
||||
}
|
||||
let held = bytes_of(&files);
|
||||
let mut playlist_verifications = Vec::new();
|
||||
for (name, about, receipt, cues, playlist, edits) in playlist_check_vectors(&sample, &cues_text) {
|
||||
let mut v = json!({"name": name, "about": about, "receipt": receipt, "cues": cues, "playlist": playlist, "edits": edits});
|
||||
let facts = run_playlist_check(&v, &held).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let verdict = facts["verdict"].as_str().expect("a verdict").to_string();
|
||||
let expect_verified =
|
||||
["playlist_out_in", "playlist_inside", "playlist_offset", "playlist_return", "playlist_none", "playlist_no_section", "playlist_fractional", "playlist_last_instant"].contains(&name);
|
||||
assert_eq!(verdict == "verified", expect_verified, "{name}: {verdict}");
|
||||
if let Some(r) = Reason::from_code(&verdict) {
|
||||
seen.insert(r.code());
|
||||
}
|
||||
v["facts"] = facts;
|
||||
playlist_verifications.push(v);
|
||||
}
|
||||
for r in Reason::ADDED.iter().chain(Reason::REUSED) {
|
||||
assert!(seen.contains(r.code()), "the corpus gives no {r}");
|
||||
}
|
||||
|
|
@ -633,6 +885,8 @@ pub fn generate() -> Vec<(String, String)> {
|
|||
"operator": {"key": hex(&operator().verifying_key().to_bytes()), "seed": hex(&operator().to_bytes())},
|
||||
"imports": imports,
|
||||
"verifications": verifications,
|
||||
"playlists": playlists,
|
||||
"playlist_verifications": playlist_verifications,
|
||||
});
|
||||
files.push(("vectors.json".into(), serde_json::to_string_pretty(&doc).expect("JSON") + "\n"));
|
||||
files.sort();
|
||||
|
|
@ -685,5 +939,25 @@ pub fn verify(files: &BTreeMap<String, String>) -> Vec<String> {
|
|||
Err(e) => fails.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
for v in doc["playlists"].as_array().into_iter().flatten() {
|
||||
let name = v["name"].as_str().unwrap_or("?");
|
||||
match run_playlist(v, &held) {
|
||||
Ok(got) => {
|
||||
let file = v["outcome"]["playlist"].as_str();
|
||||
if playlist_outcome(&got, file) != v["outcome"] || got.as_ref().is_ok_and(|t| file.and_then(|f| files.get(f)) != Some(t)) {
|
||||
fails.push(format!("{name}: renders {:?}, the corpus states {}", got.as_ref().map(|_| "a playlist").map_err(|u| u.reason), v["outcome"]));
|
||||
}
|
||||
}
|
||||
Err(e) => fails.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
for v in doc["playlist_verifications"].as_array().into_iter().flatten() {
|
||||
let name = v["name"].as_str().unwrap_or("?");
|
||||
match run_playlist_check(v, &held) {
|
||||
Ok(f) if f == v["facts"] => {}
|
||||
Ok(f) => fails.push(format!("{name}: states {f}, the corpus {}", v["facts"])),
|
||||
Err(e) => fails.push(format!("{name}: {e}")),
|
||||
}
|
||||
}
|
||||
fails
|
||||
}
|
||||
|
|
|
|||
471
wai/wai-rs/src/channel_lineup/cue_tags.rs
Normal file
471
wai/wai-rs/src/channel_lineup/cue_tags.rs
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
//! The cue tags of an HLS Media Playlist (extensions/channel-lineup.md §7.4, §8.4).
|
||||
//!
|
||||
//! A packager that writes a channel's segments, MPEG-2 transport stream segments among them,
|
||||
//! marks each break of the receipt's schedule in the playlist:
|
||||
//! `EXT-X-CUE-OUT` with the break's duration, `EXT-X-CUE-OUT-CONT` on each later segment of the
|
||||
//! break and `EXT-X-CUE-IN` on its return; and, for a break whose cue carries its SCTE-35
|
||||
//! section, the `EXT-X-DATERANGE` tags of RFC 8216 §4.3.2.7.1. [`render`] writes a window's
|
||||
//! playlist from the breaks [`bind_cues`] reads from a receipt and its cues; [`verify_playlist`]
|
||||
//! reads a playlist's window back, renders it again and compares.
|
||||
//!
|
||||
//! Pure functions of the bytes they are given: no I/O, no clock and no floating point.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::import::{break_cue, section_reads, Cue};
|
||||
use super::{duration_ms, hex, hex_bytes, hls_date_time, read_receipt, receipt_verdict, xmltv_format, Reason, MAX_INSTANT};
|
||||
use crate::strict_json::{self, Json};
|
||||
use crate::wai_video_channel::{ChannelReceipt, EntryKind};
|
||||
|
||||
/// `EXT-X-VERSION`: a decimal `EXTINF` duration needs version 3 (RFC 8216 §7).
|
||||
pub const VERSION: u8 = 3;
|
||||
/// The most bytes of a segment's URI (§7.4).
|
||||
pub const MAX_URI: usize = 2048;
|
||||
/// The longest segment, in milliseconds (§7.4).
|
||||
pub const MAX_SEGMENT_MS: u64 = u32::MAX as u64;
|
||||
/// The instant a window ends before: 10000-01-01T00:00:00Z. A break in the window starts at the
|
||||
/// latest where the window ends, so every date it writes has four digits of year (§7.4).
|
||||
pub const WINDOW_ENDS_BEFORE: u64 = 253_402_300_800_000;
|
||||
|
||||
/// A break of the receipt's schedule (§7.4): an `ad` entry of a duration above 0, with the
|
||||
/// section its cue carries, if any.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Break {
|
||||
pub id: String,
|
||||
pub start_ms: u64,
|
||||
pub duration_ms: u64,
|
||||
pub section: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Break {
|
||||
pub fn end_ms(&self) -> u64 {
|
||||
self.start_ms + self.duration_ms
|
||||
}
|
||||
|
||||
/// The `ID` of its date range: its id, `@` and its start in decimal (§7.4).
|
||||
pub fn date_range_id(&self) -> String {
|
||||
format!("{}@{}", self.id, self.start_ms)
|
||||
}
|
||||
|
||||
/// Whether a quoted-string can hold its date range's `ID` (RFC 8216 §4.2).
|
||||
fn writable(&self) -> bool {
|
||||
!self.id.contains(['"', '\n', '\r'])
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment of a window: its URI and its duration in milliseconds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
pub uri: String,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// What a Media Playlist lists (§7.4): its media sequence number, the instant its first segment
|
||||
/// begins, its segments in order, and whether it is ended.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub media_sequence: u64,
|
||||
pub start_ms: u64,
|
||||
pub segments: Vec<Segment>,
|
||||
pub ended: bool,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Each segment's start and end.
|
||||
fn spans(&self) -> Vec<(u64, u64)> {
|
||||
let mut at = self.start_ms;
|
||||
self.segments
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let span = (at, at.saturating_add(s.duration_ms));
|
||||
at = span.1;
|
||||
span
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether it holds to §7.4's bounds.
|
||||
pub fn holds(&self) -> bool {
|
||||
let uri = |u: &str| (1..=MAX_URI).contains(&u.len()) && u.bytes().all(|c| (0x21..=0x7e).contains(&c)) && !u.starts_with('#');
|
||||
self.media_sequence <= MAX_INSTANT
|
||||
&& !self.segments.is_empty()
|
||||
&& self.segments.iter().all(|s| uri(&s.uri) && (1..=MAX_SEGMENT_MS).contains(&s.duration_ms))
|
||||
&& self.segments.iter().try_fold(self.start_ms, |at, s| at.checked_add(s.duration_ms)).is_some_and(|end| end < WINDOW_ENDS_BEFORE)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt's breaks (§7.4): its `ad` entries of a duration above 0, in schedule order, each
|
||||
/// its id, its start and its duration.
|
||||
pub fn schedule_breaks(r: &ChannelReceipt) -> Vec<(String, u64, u64)> {
|
||||
r.schedule.iter().filter(|e| e.kind == EntryKind::Ad && e.duration_ms > 0).map(|e| (e.asset_id.clone(), e.start_ms, e.duration_ms)).collect()
|
||||
}
|
||||
|
||||
/// Read a cues document (§8.4 step 4): one JSON text, an array of objects of exactly `break` (a
|
||||
/// string), `start_ms` (an integer of at most 2^53 − 1) and `payload` (an even count of lowercase
|
||||
/// hexadecimal digits), as `channel.import` writes `cues.json`.
|
||||
pub fn read_cues(b: &[u8]) -> Option<Vec<Cue>> {
|
||||
let j = strict_json::read(b).ok()?;
|
||||
let mut out = Vec::new();
|
||||
for c in j.as_arr()? {
|
||||
let m = c.as_obj()?;
|
||||
if m.len() != 3 || !["break", "start_ms", "payload"].iter().all(|k| m.iter().any(|(n, _)| n == k)) {
|
||||
return None;
|
||||
}
|
||||
let start_ms = c.get("start_ms").and_then(Json::as_u64).filter(|&t| t <= MAX_INSTANT)?;
|
||||
out.push(Cue { break_id: c.get("break")?.as_str()?.to_string(), start_ms, payload: hex_bytes(c.get("payload")?.as_str()?)? });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The section a cue's payload carries: `Some(Some(_))` for one of form 1 that reads as §4.5 step
|
||||
/// 3 reads a section, `Some(None)` for form 0, `None` otherwise.
|
||||
fn payload_section(p: &[u8]) -> Option<Option<&[u8]>> {
|
||||
match p.get(9)? {
|
||||
0 => Some(None),
|
||||
1 => {
|
||||
let n = u16::from_le_bytes([*p.get(10)?, *p.get(11)?]) as usize;
|
||||
let s = p.get(12..12 + n)?;
|
||||
section_reads(s).then_some(Some(s))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// §8.4 step 5: the breaks of the receipt, each with the section its cue carries; or the first
|
||||
/// index whose cue is not its break's, a missing or an extra cue at the first index past the
|
||||
/// shorter list.
|
||||
pub fn bind_cues(r: &ChannelReceipt, cues: &[Cue]) -> Result<Vec<Break>, usize> {
|
||||
let breaks = schedule_breaks(r);
|
||||
let mut out = Vec::with_capacity(breaks.len());
|
||||
for i in 0..breaks.len().max(cues.len()) {
|
||||
let (Some((id, start, duration)), Some(c)) = (breaks.get(i), cues.get(i)) else { return Err(i) };
|
||||
if c.break_id != *id || c.start_ms != *start || *duration > MAX_SEGMENT_MS {
|
||||
return Err(i);
|
||||
}
|
||||
let Some(section) = payload_section(&c.payload) else { return Err(i) };
|
||||
if break_cue(id, *duration, section) != c.payload {
|
||||
return Err(i);
|
||||
}
|
||||
out.push(Break { id: id.clone(), start_ms: *start, duration_ms: *duration, section: section.map(<[u8]>::to_vec) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A duration as a span (§7.4): seconds with three digits after the point.
|
||||
pub fn span(ms: u64) -> String {
|
||||
format!("{}.{:03}", ms / 1000, ms % 1000)
|
||||
}
|
||||
|
||||
/// An instant as a date (§7.4): `YYYY-MM-DDTHH:MM:SS.mmmZ`, in UTC.
|
||||
pub fn date(ms: u64) -> String {
|
||||
let s = xmltv_format(ms);
|
||||
format!("{}-{}-{}T{}:{}:{}.{:03}Z", &s[0..4], &s[4..6], &s[6..8], &s[8..10], &s[10..12], &s[12..14], ms % 1000)
|
||||
}
|
||||
|
||||
/// Why a window's tags cannot be written (§7.4), with the index §8.4 states: the segment's for
|
||||
/// `cue_unaligned`, the break's for `cue_unwritable`, none for `playlist_malformed`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Unwritten {
|
||||
pub reason: Reason,
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
/// The first segment of the window that puts a break in it (§7.4): its last segment before the
|
||||
/// break, a segment that starts in it, or its return.
|
||||
fn first_in(b: &Break, spans: &[(u64, u64)]) -> Option<usize> {
|
||||
spans.iter().position(|&(s, e)| e == b.start_ms || (b.start_ms <= s && s < b.end_ms()) || s == b.end_ms())
|
||||
}
|
||||
|
||||
/// The breaks that are in the window, as `(index among the breaks, break)`.
|
||||
pub fn in_window<'a>(breaks: &'a [Break], w: &Window) -> Vec<(usize, &'a Break)> {
|
||||
let spans = w.spans();
|
||||
breaks.iter().enumerate().filter(|(_, b)| first_in(b, &spans).is_some()).collect()
|
||||
}
|
||||
|
||||
/// The Media Playlist of a window, its breaks marked (§7.4).
|
||||
pub fn render(breaks: &[Break], w: &Window) -> Result<String, Unwritten> {
|
||||
if !w.holds() {
|
||||
return Err(Unwritten { reason: Reason::PlaylistMalformed, index: None });
|
||||
}
|
||||
let spans = w.spans();
|
||||
let inside = |x: u64, (s, e): (u64, u64)| s < x && x < e;
|
||||
if let Some(k) = spans.iter().position(|&sp| breaks.iter().any(|b| inside(b.start_ms, sp) || inside(b.end_ms(), sp))) {
|
||||
return Err(Unwritten { reason: Reason::CueUnaligned, index: Some(k) });
|
||||
}
|
||||
if let Some((i, _)) = in_window(breaks, w).into_iter().find(|(_, b)| b.section.is_some() && !b.writable()) {
|
||||
return Err(Unwritten { reason: Reason::CueUnwritable, index: Some(i) });
|
||||
}
|
||||
let target = w.segments.iter().map(|s| s.duration_ms.div_ceil(1000)).max().expect("a segment");
|
||||
let mut o = format!(
|
||||
"#EXTM3U\n#EXT-X-VERSION:{VERSION}\n#EXT-X-TARGETDURATION:{target}\n#EXT-X-MEDIA-SEQUENCE:{}\n#EXT-X-PROGRAM-DATE-TIME:{}\n",
|
||||
w.media_sequence,
|
||||
date(w.start_ms)
|
||||
);
|
||||
for (k, (seg, &(s, _))) in w.segments.iter().zip(&spans).enumerate() {
|
||||
for b in breaks {
|
||||
let Some(section) = &b.section else { continue };
|
||||
let id = b.date_range_id();
|
||||
if first_in(b, &spans) == Some(k) {
|
||||
o.push_str(&format!(
|
||||
"#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",PLANNED-DURATION={},SCTE35-OUT=0x{}\n",
|
||||
date(b.start_ms),
|
||||
span(b.duration_ms),
|
||||
hex(section).to_uppercase()
|
||||
));
|
||||
}
|
||||
if s == b.end_ms() {
|
||||
o.push_str(&format!("#EXT-X-DATERANGE:ID=\"{id}\",START-DATE=\"{}\",DURATION={}\n", date(b.start_ms), span(b.duration_ms)));
|
||||
}
|
||||
}
|
||||
if breaks.iter().any(|b| b.end_ms() == s) {
|
||||
o.push_str("#EXT-X-CUE-IN\n");
|
||||
}
|
||||
if let Some(b) = breaks.iter().find(|b| b.start_ms == s) {
|
||||
o.push_str(&format!("#EXT-X-CUE-OUT:{}\n", span(b.duration_ms)));
|
||||
} else if let Some(b) = breaks.iter().find(|b| b.start_ms < s && s < b.end_ms()) {
|
||||
o.push_str(&format!("#EXT-X-CUE-OUT-CONT:ElapsedTime={},Duration={}\n", span(s - b.start_ms), span(b.duration_ms)));
|
||||
}
|
||||
o.push_str(&format!("#EXTINF:{},\n{}\n", span(seg.duration_ms), seg.uri));
|
||||
}
|
||||
if w.ended {
|
||||
o.push_str("#EXT-X-ENDLIST\n");
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A decimal integer with no leading zero, at most 2^53 − 1.
|
||||
fn plain(s: &str) -> Option<u64> {
|
||||
(!s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))).then(|| s.parse().ok()).flatten().filter(|&n| n <= MAX_INSTANT)
|
||||
}
|
||||
|
||||
/// Read a Media Playlist's window (§8.4 step 6), or `None`: `playlist_malformed`.
|
||||
pub fn read_playlist(b: &[u8]) -> Option<Window> {
|
||||
let lines: Vec<&str> = std::str::from_utf8(b).ok()?.strip_suffix('\n')?.split('\n').collect();
|
||||
if lines.first() != Some(&"#EXTM3U") {
|
||||
return None;
|
||||
}
|
||||
let (mut sequence, mut first, mut ended, mut segments) = (None, None, false, Vec::new());
|
||||
let mut i = 1;
|
||||
while i < lines.len() {
|
||||
let l = lines[i];
|
||||
if let Some(v) = l.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
|
||||
if sequence.replace(plain(v)?).is_some() {
|
||||
return None;
|
||||
}
|
||||
} else if let Some(v) = l.strip_prefix("#EXT-X-PROGRAM-DATE-TIME:") {
|
||||
if first.is_none() && segments.is_empty() {
|
||||
first = Some(hls_date_time(v).ok()?);
|
||||
}
|
||||
} else if l == "#EXT-X-ENDLIST" {
|
||||
ended = true;
|
||||
} else if let Some(v) = l.strip_prefix("#EXTINF:") {
|
||||
let d = duration_ms(v.strip_suffix(',')?).ok()?;
|
||||
let uri = lines.get(i + 1)?;
|
||||
segments.push(Segment { uri: uri.to_string(), duration_ms: d });
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let w = Window { media_sequence: sequence?, start_ms: first?, segments, ended };
|
||||
w.holds().then_some(w)
|
||||
}
|
||||
|
||||
/// A break a window holds, as a playlist's verification states it (§8.4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Marked {
|
||||
pub id: String,
|
||||
pub start_ms: u64,
|
||||
pub duration_ms: u64,
|
||||
/// Whether it has a date range: whether its cue carries a section.
|
||||
pub date_range: bool,
|
||||
}
|
||||
|
||||
/// What a playlist's verification states (§8.4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PlaylistCheck {
|
||||
/// `Ok` when verified, otherwise the reason.
|
||||
pub verdict: Result<(), Reason>,
|
||||
/// `receipt`, `cues` or `playlist`.
|
||||
pub part: Option<&'static str>,
|
||||
/// The receipt's identity, when it reads.
|
||||
pub receipt: Option<[u8; 32]>,
|
||||
/// The window, when the playlist reads, and the breaks in it.
|
||||
pub window: Option<(Window, Vec<Marked>)>,
|
||||
/// For `cues_mismatch` the cue's index, for `cue_unaligned` the segment's, for
|
||||
/// `cue_unwritable` the break's.
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Verify a playlist's cue tags against a receipt and its cues (§8.4).
|
||||
pub fn verify_playlist(receipt: &[u8], cues: &[u8], playlist: &[u8]) -> PlaylistCheck {
|
||||
let mut v = PlaylistCheck { verdict: Ok(()), part: None, receipt: None, window: None, index: None };
|
||||
let fail = |mut v: PlaylistCheck, part: &'static str, r: Reason, index: Option<usize>| {
|
||||
v.verdict = Err(r);
|
||||
v.part = Some(part);
|
||||
v.index = index;
|
||||
v
|
||||
};
|
||||
let Some(r) = read_receipt(receipt) else { return fail(v, "receipt", Reason::ChannelReceiptMalformed, None) };
|
||||
v.receipt = Some(r.receipt_hash());
|
||||
if let Err(e) = receipt_verdict(&r) {
|
||||
return fail(v, "receipt", e, None);
|
||||
}
|
||||
let Some(cues) = read_cues(cues) else { return fail(v, "cues", Reason::CuesMalformed, None) };
|
||||
let breaks = match bind_cues(&r, &cues) {
|
||||
Ok(b) => b,
|
||||
Err(i) => return fail(v, "cues", Reason::CuesMismatch, Some(i)),
|
||||
};
|
||||
let Some(w) = read_playlist(playlist) else { return fail(v, "playlist", Reason::PlaylistMalformed, None) };
|
||||
let marked = in_window(&breaks, &w).into_iter().map(|(_, b)| Marked { id: b.id.clone(), start_ms: b.start_ms, duration_ms: b.duration_ms, date_range: b.section.is_some() }).collect();
|
||||
v.window = Some((w.clone(), marked));
|
||||
match render(&breaks, &w) {
|
||||
Err(u) => fail(v, "playlist", u.reason, u.index),
|
||||
Ok(text) if text.as_bytes() != playlist => fail(v, "playlist", Reason::PlaylistMismatch, None),
|
||||
Ok(_) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// A window as the corpus states it: `media_sequence`, `start_ms`, `segments` (each `uri` and
|
||||
/// `duration_ms`) and `ended`.
|
||||
pub fn window_json(w: &Window) -> Value {
|
||||
json!({
|
||||
"media_sequence": w.media_sequence,
|
||||
"start_ms": w.start_ms,
|
||||
"segments": w.segments.iter().map(|s| json!({"uri": s.uri, "duration_ms": s.duration_ms})).collect::<Vec<_>>(),
|
||||
"ended": w.ended,
|
||||
})
|
||||
}
|
||||
|
||||
/// A window from [`window_json`]'s form, or `None`.
|
||||
pub fn window_of(v: &Value) -> Option<Window> {
|
||||
let segments = v["segments"].as_array()?.iter().map(|s| Some(Segment { uri: s["uri"].as_str()?.to_string(), duration_ms: s["duration_ms"].as_u64()? })).collect::<Option<Vec<_>>>()?;
|
||||
Some(Window { media_sequence: v["media_sequence"].as_u64()?, start_ms: v["start_ms"].as_u64()?, segments, ended: v["ended"].as_bool()? })
|
||||
}
|
||||
|
||||
/// A playlist's verification as JSON (§8.4; the corpus's `facts`).
|
||||
pub fn playlist_check_json(v: &PlaylistCheck) -> Value {
|
||||
json!({
|
||||
"verdict": v.verdict.err().map_or("verified", Reason::code),
|
||||
"part": v.part,
|
||||
"receipt": v.receipt.map(|h| hex(&h)),
|
||||
"window": v.window.as_ref().map(|(w, marked)| json!({
|
||||
"media_sequence": w.media_sequence,
|
||||
"start_ms": w.start_ms,
|
||||
"segments": w.segments.len(),
|
||||
"ended": w.ended,
|
||||
"breaks": marked.iter().map(|m| json!({"break": m.id, "start_ms": m.start_ms, "duration_ms": m.duration_ms, "scte35": m.date_range})).collect::<Vec<_>>(),
|
||||
})),
|
||||
"index": v.index,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn brk(id: &str, start_ms: u64, duration_ms: u64, section: bool) -> Break {
|
||||
Break { id: id.into(), start_ms, duration_ms, section: section.then(|| vec![0xFC, 0x30, 0x00]) }
|
||||
}
|
||||
|
||||
fn win(seq: u64, start_ms: u64, n: usize) -> Window {
|
||||
Window { media_sequence: seq, start_ms, segments: (0..n).map(|k| Segment { uri: format!("s{}.ts", seq + k as u64), duration_ms: 10_000 }).collect(), ended: false }
|
||||
}
|
||||
|
||||
/// The lines before each segment's `EXTINF`, by the segment's URI.
|
||||
fn cue_lines(text: &str) -> Vec<(String, Vec<String>, Vec<String>)> {
|
||||
let mut out = Vec::new();
|
||||
let (mut daterange, mut cue) = (Vec::new(), Vec::new());
|
||||
let mut lines = text.lines().skip(5);
|
||||
while let Some(l) = lines.next() {
|
||||
if l.starts_with("#EXTINF:") {
|
||||
out.push((lines.next().expect("a URI").to_string(), std::mem::take(&mut daterange), std::mem::take(&mut cue)));
|
||||
} else if l.starts_with("#EXT-X-DATERANGE:") {
|
||||
daterange.push(l.to_string());
|
||||
} else if l.starts_with("#EXT-X-CUE") {
|
||||
cue.push(l.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_to_back_breaks_close_the_first_then_open_the_second() {
|
||||
let breaks = [brk("a", 20_000, 20_000, true), brk("b", 40_000, 10_000, true)];
|
||||
let text = render(&breaks, &win(0, 0, 6)).expect("aligned");
|
||||
let at_40 = &cue_lines(&text)[4];
|
||||
assert_eq!(at_40.0, "s4.ts");
|
||||
assert_eq!(at_40.2, ["#EXT-X-CUE-IN", "#EXT-X-CUE-OUT:10.000"]);
|
||||
assert!(at_40.1[0].contains("ID=\"a@20000\"") && at_40.1[0].contains("DURATION=20.000"), "{:?}", at_40.1);
|
||||
// b's range was announced with the last segment before it, s3, which is in a.
|
||||
let at_30 = &cue_lines(&text)[3];
|
||||
assert_eq!(at_30.2, ["#EXT-X-CUE-OUT-CONT:ElapsedTime=10.000,Duration=20.000"]);
|
||||
assert!(at_30.1[0].contains("ID=\"b@40000\"") && at_30.1[0].contains("PLANNED-DURATION=10.000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_carries_the_same_cue_lines_in_every_window_that_holds_it() {
|
||||
let breaks = [brk("a", 50_000, 40_000, true)];
|
||||
let all = cue_lines(&render(&breaks, &win(0, 0, 12)).expect("aligned"));
|
||||
for first in 0..12 {
|
||||
let part = cue_lines(&render(&breaks, &win(first as u64, first as u64 * 10_000, 12 - first)).expect("aligned"));
|
||||
for (k, seg) in part.iter().enumerate() {
|
||||
assert_eq!(seg.2, all[first + k].2, "window from {first}, segment {k}");
|
||||
}
|
||||
// The range is in every window holding s4 (its last segment before it) to s9 (its
|
||||
// return), and its out tag is on the first of those the window holds.
|
||||
let outs: Vec<usize> = part.iter().enumerate().filter(|(_, s)| s.1.iter().any(|l| l.contains("SCTE35-OUT"))).map(|(k, _)| k).collect();
|
||||
let want: Vec<usize> = if first <= 4 { vec![4 - first] } else if first <= 9 { vec![0] } else { vec![] };
|
||||
assert_eq!(outs, want, "window from {first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_with_no_section_has_no_date_range_and_its_id_is_never_written() {
|
||||
let breaks = [brk("quote\"d", 10_000, 10_000, false)];
|
||||
let text = render(&breaks, &win(0, 0, 3)).expect("no ID is written");
|
||||
assert!(!text.contains("DATERANGE"));
|
||||
assert_eq!(cue_lines(&text)[1].2, ["#EXT-X-CUE-OUT:10.000"]);
|
||||
assert_eq!(cue_lines(&text)[2].2, ["#EXT-X-CUE-IN"]);
|
||||
let with = [brk("quote\"d", 10_000, 10_000, true)];
|
||||
assert_eq!(render(&with, &win(0, 0, 3)), Err(Unwritten { reason: Reason::CueUnwritable, index: Some(0) }));
|
||||
// Outside the window, the same break is not written, so it does not refuse.
|
||||
assert!(render(&with, &win(5, 50_000, 2)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rendering_reads_back_to_its_window() {
|
||||
let breaks = [brk("a", 20_000, 20_000, true)];
|
||||
let mut w = win(7, 1_793_296_800_000, 6);
|
||||
w.ended = true;
|
||||
let text = render(&[Break { start_ms: w.start_ms + 20_000, ..breaks[0].clone() }], &w).expect("aligned");
|
||||
assert_eq!(read_playlist(text.as_bytes()), Some(w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_past_its_bounds_is_malformed() {
|
||||
let mut w = win(0, WINDOW_ENDS_BEFORE - 10_001, 1);
|
||||
assert!(w.holds());
|
||||
w.segments[0].duration_ms += 1;
|
||||
assert!(!w.holds());
|
||||
let mut w = win(0, 0, 1);
|
||||
w.segments[0].uri = "#seg.ts".into();
|
||||
assert_eq!(render(&[], &w).map_err(|u| u.reason), Err(Reason::PlaylistMalformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_at_the_last_instant_is_dated_in_the_year_9999() {
|
||||
let last = WINDOW_ENDS_BEFORE - 1;
|
||||
let breaks = [brk("a", last, 1, true), brk("b", WINDOW_ENDS_BEFORE, 30_000, true)];
|
||||
// The window ends where a starts, so a is in it and b is not.
|
||||
let text = render(&breaks, &win(0, last - 10_000, 1)).expect("in its bounds");
|
||||
assert!(text.contains("#EXT-X-PROGRAM-DATE-TIME:9999-12-31T23:59:49.999Z\n"), "{text}");
|
||||
assert!(text.contains("ID=\"a@253402300799999\",START-DATE=\"9999-12-31T23:59:59.999Z\",PLANNED-DURATION=0.001,"), "{text}");
|
||||
assert!(!text.contains("b@"), "{text}");
|
||||
// With a segment of a millisecond after it, the window would hold b's last segment before
|
||||
// it, and b's date has five digits of year: the window is past its bounds.
|
||||
let mut w = win(0, last - 10_000, 2);
|
||||
w.segments[1].duration_ms = 1;
|
||||
assert_eq!(render(&breaks, &w), Err(Unwritten { reason: Reason::PlaylistMalformed, index: None }));
|
||||
assert_eq!(read_playlist(text.as_bytes()).map(|w| w.start_ms), Some(last - 10_000));
|
||||
}
|
||||
}
|
||||
|
|
@ -418,7 +418,7 @@ fn hex_any(s: &str) -> Option<Vec<u8>> {
|
|||
}
|
||||
|
||||
/// A `splice_info_section()` as live-events §4.3 reads form 1 (§4.5 step 3).
|
||||
fn section_reads(b: &[u8]) -> bool {
|
||||
pub(crate) fn section_reads(b: &[u8]) -> bool {
|
||||
b.len() >= 3 && b[0] == 0xFC && (u16::from_be_bytes([b[1], b[2]]) & 0x0FFF) as usize + 3 == b.len()
|
||||
}
|
||||
|
||||
|
|
@ -596,10 +596,16 @@ pub fn cue_id(break_id: &str, section: Option<&[u8]>) -> u32 {
|
|||
|
||||
/// A break's `cue` payload of live-events §4.3 (§4.8).
|
||||
fn cue_payload(k: &Break) -> Vec<u8> {
|
||||
break_cue(&k.id, k.duration, k.section.as_deref())
|
||||
}
|
||||
|
||||
/// The `cue` payload of live-events §4.3 that §4.8 yields for a break of this id and duration,
|
||||
/// with this section or none. A duration is at most 2^32 − 1 milliseconds (§4.5 step 4).
|
||||
pub fn break_cue(id: &str, duration_ms: u64, section: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut o = vec![4u8];
|
||||
o.extend_from_slice(&cue_id(&k.id, k.section.as_deref()).to_le_bytes());
|
||||
o.extend_from_slice(&(k.duration as u32).to_le_bytes());
|
||||
match &k.section {
|
||||
o.extend_from_slice(&cue_id(id, section).to_le_bytes());
|
||||
o.extend_from_slice(&(duration_ms as u32).to_le_bytes());
|
||||
match section {
|
||||
None => o.push(0),
|
||||
Some(s) => {
|
||||
o.push(1);
|
||||
|
|
|
|||
|
|
@ -865,6 +865,7 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("wai-route-policy", WireKind::DocumentKind, ROUTE_RECORD, "§3.3", WireStatus::Defined),
|
||||
wire("wai-route-record", WireKind::DocumentKind, ROUTE_RECORD, "§5.1", WireStatus::Defined),
|
||||
wire("wai-route-third-party-hop", WireKind::DocumentKind, ROUTE_RECORD, "§6.2", WireStatus::Defined),
|
||||
wire("wai-route-delivery-decision", WireKind::DocumentKind, ROUTE_RECORD, "§6.3", WireStatus::Defined),
|
||||
wire("WRC1", WireKind::Magic, REVENUE_SHARE, "§3.1", WireStatus::Defined),
|
||||
wire("WRS1", WireKind::Magic, REVENUE_SHARE, "§7.1", WireStatus::Defined),
|
||||
wire("WOR1", WireKind::Magic, OPPORTUNITY, "§7.1", WireStatus::Defined),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ use serde_json::{json, Map, Value};
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub mod corpus;
|
||||
pub mod delivery;
|
||||
pub mod third_party;
|
||||
|
||||
/// The record this extension registers.
|
||||
|
|
@ -103,6 +104,10 @@ pub enum Reason {
|
|||
ThirdPartyInvalid,
|
||||
ThirdPartyMismatch,
|
||||
ThirdPartyOutputMismatch,
|
||||
DeliveryInvalid,
|
||||
DeliveryRouteMismatch,
|
||||
DeliveryParentMismatch,
|
||||
DeliveryChainBroken,
|
||||
// Reused (§8).
|
||||
Malformed,
|
||||
UnknownVersion,
|
||||
|
|
@ -112,7 +117,7 @@ pub enum Reason {
|
|||
|
||||
impl Reason {
|
||||
/// The codes this extension adds, in §8's order.
|
||||
pub const ALL: [Reason; 25] = [
|
||||
pub const ALL: [Reason; 29] = [
|
||||
Reason::RequestInvalid,
|
||||
Reason::PricebookInvalid,
|
||||
Reason::PolicyInvalid,
|
||||
|
|
@ -138,6 +143,10 @@ impl Reason {
|
|||
Reason::ThirdPartyInvalid,
|
||||
Reason::ThirdPartyMismatch,
|
||||
Reason::ThirdPartyOutputMismatch,
|
||||
Reason::DeliveryInvalid,
|
||||
Reason::DeliveryRouteMismatch,
|
||||
Reason::DeliveryParentMismatch,
|
||||
Reason::DeliveryChainBroken,
|
||||
];
|
||||
|
||||
/// The codes it reuses (§8).
|
||||
|
|
@ -170,6 +179,10 @@ impl Reason {
|
|||
Reason::ThirdPartyInvalid => "third_party_invalid",
|
||||
Reason::ThirdPartyMismatch => "third_party_mismatch",
|
||||
Reason::ThirdPartyOutputMismatch => "third_party_output_mismatch",
|
||||
Reason::DeliveryInvalid => "delivery_invalid",
|
||||
Reason::DeliveryRouteMismatch => "delivery_route_mismatch",
|
||||
Reason::DeliveryParentMismatch => "delivery_parent_mismatch",
|
||||
Reason::DeliveryChainBroken => "delivery_chain_broken",
|
||||
Reason::Malformed => "malformed",
|
||||
Reason::UnknownVersion => "unknown_version",
|
||||
Reason::BadKey => "bad_key",
|
||||
|
|
@ -1142,6 +1155,45 @@ pub fn verify_output_proof(output: &[u8; 32], index: u64, siblings: &[[u8; 32]],
|
|||
&node == root
|
||||
}
|
||||
|
||||
/// A candidate of a record of `version` (§5.1): each member of its own form. Relations between
|
||||
/// members are §7.2 step 6's.
|
||||
fn candidate_form(c: &Value, version: u64) -> bool {
|
||||
let int_or_null = |v: &Value| v.is_null() || int(v).is_some();
|
||||
let bool_or_null = |v: &Value| v.is_null() || v.is_boolean();
|
||||
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
|
||||
let scalar = |v: &Value| !(v.is_array() || v.is_object());
|
||||
let Some(c) = exactly(c, &candidate_members(version)) else { return false };
|
||||
let v2 = book_version(version) != VERSION_2
|
||||
|| (c["operation"].as_str().is_some_and(operation)
|
||||
&& (c["failures"].is_null() || Failures::form(&c["failures"]).is_some())
|
||||
&& int_or_null(&c["expected_cost"])
|
||||
&& int_or_null(&c["expected_ttfb_ms"]));
|
||||
v2 && tok(&c["endpoint"]).is_some()
|
||||
&& tok(&c["provider"]).is_some()
|
||||
&& c["capability"].as_str().is_some_and(capability)
|
||||
&& tok_or_null(&c["region"])
|
||||
&& tok_or_null(&c["residency"])
|
||||
&& bool_or_null(&c["trains_on_inputs"])
|
||||
&& int_or_null(&c["retention_days"])
|
||||
&& bool_or_null(&c["ga"])
|
||||
&& bool_or_null(&c["needs_consent"])
|
||||
&& c["unit"].as_str().is_some_and(unit)
|
||||
&& int(&c["divisor"]).is_some_and(|d| d >= 1)
|
||||
&& int(&c["price"]).is_some()
|
||||
&& int_or_null(&c["ttfb_ms"])
|
||||
&& int_or_null(&c["energy_uj"])
|
||||
&& (c["energy_class"].is_null() || c["energy_class"].as_str().is_some_and(|x| CLASSES.contains(&x)))
|
||||
&& (c["quality"].is_null() || int(&c["quality"]).is_some_and(|q| q <= 1000))
|
||||
&& c["via_fallback"].is_boolean()
|
||||
&& int_or_null(&c["cost"])
|
||||
&& int_or_null(&c["energy"])
|
||||
&& c["removed"].as_array().is_some_and(|r| {
|
||||
r.len() <= LIMITS.len()
|
||||
&& r.iter().all(|x| exactly(x, &["limit", "value"]).is_some_and(|x| x["limit"].as_str().is_some_and(|l| LIMITS.contains(&l)) && scalar(&x["value"])))
|
||||
})
|
||||
&& (c["rank"].is_null() || int(&c["rank"]).is_some_and(|n| n >= 1))
|
||||
}
|
||||
|
||||
fn record_form(v: &Value) -> bool {
|
||||
let version = v.get("version").and_then(int).unwrap_or(0);
|
||||
let mut attempt_members = ATTEMPT_MEMBERS.to_vec();
|
||||
|
|
@ -1155,41 +1207,7 @@ fn record_form(v: &Value) -> bool {
|
|||
let hexn = |v: &Value, n: usize| v.as_str().is_some_and(|s| s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)));
|
||||
let cap_or_null = |v: &Value| v.is_null() || v.as_str().is_some_and(capability);
|
||||
let int_or_null = |v: &Value| v.is_null() || int(v).is_some();
|
||||
let bool_or_null = |v: &Value| v.is_null() || v.is_boolean();
|
||||
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
|
||||
let scalar = |v: &Value| !(v.is_array() || v.is_object());
|
||||
let candidate = |c: &Value| -> bool {
|
||||
let Some(c) = exactly(c, &candidate_members(version)) else { return false };
|
||||
let v2 = book_version(version) != VERSION_2
|
||||
|| (c["operation"].as_str().is_some_and(operation)
|
||||
&& (c["failures"].is_null() || Failures::form(&c["failures"]).is_some())
|
||||
&& int_or_null(&c["expected_cost"])
|
||||
&& int_or_null(&c["expected_ttfb_ms"]));
|
||||
v2 && tok(&c["endpoint"]).is_some()
|
||||
&& tok(&c["provider"]).is_some()
|
||||
&& c["capability"].as_str().is_some_and(capability)
|
||||
&& tok_or_null(&c["region"])
|
||||
&& tok_or_null(&c["residency"])
|
||||
&& bool_or_null(&c["trains_on_inputs"])
|
||||
&& int_or_null(&c["retention_days"])
|
||||
&& bool_or_null(&c["ga"])
|
||||
&& bool_or_null(&c["needs_consent"])
|
||||
&& c["unit"].as_str().is_some_and(unit)
|
||||
&& int(&c["divisor"]).is_some_and(|d| d >= 1)
|
||||
&& int(&c["price"]).is_some()
|
||||
&& int_or_null(&c["ttfb_ms"])
|
||||
&& int_or_null(&c["energy_uj"])
|
||||
&& (c["energy_class"].is_null() || c["energy_class"].as_str().is_some_and(|x| CLASSES.contains(&x)))
|
||||
&& (c["quality"].is_null() || int(&c["quality"]).is_some_and(|q| q <= 1000))
|
||||
&& c["via_fallback"].is_boolean()
|
||||
&& int_or_null(&c["cost"])
|
||||
&& int_or_null(&c["energy"])
|
||||
&& c["removed"].as_array().is_some_and(|r| {
|
||||
r.len() <= LIMITS.len()
|
||||
&& r.iter().all(|x| exactly(x, &["limit", "value"]).is_some_and(|x| x["limit"].as_str().is_some_and(|l| LIMITS.contains(&l)) && scalar(&x["value"])))
|
||||
})
|
||||
&& (c["rank"].is_null() || int(&c["rank"]).is_some_and(|n| n >= 1))
|
||||
};
|
||||
let attempt = |i: usize, a: &Value| -> bool {
|
||||
let Some(a) = exactly(a, &attempt_members) else { return false };
|
||||
let served = match a["outcome"].as_str() {
|
||||
|
|
@ -1234,7 +1252,7 @@ fn record_form(v: &Value) -> bool {
|
|||
.is_some_and(|p| tok(&p["name"]).is_some() && int(&p["version"]).is_some_and(|v| v >= 1) && hexn(&p["digest"], 64))
|
||||
&& exactly(&o["pricebook"], &["digest", "currency", "issued"])
|
||||
.is_some_and(|p| hexn(&p["digest"], 64) && p["currency"].as_str().is_some_and(currency) && int(&p["issued"]).is_some())
|
||||
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(candidate))
|
||||
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(|c| candidate_form(c, version)))
|
||||
&& o["order"].as_array().is_some_and(|x| x.len() <= 1024 && x.iter().all(|e| tok(e).is_some()))
|
||||
&& exactly(&o["decision"], &["decided_by", "runner_up"])
|
||||
.is_some_and(|d| d["decided_by"].as_str().is_some_and(|x| DECIDED_BY.contains(&x)) && tok_or_null(&d["runner_up"]))
|
||||
|
|
@ -1274,6 +1292,9 @@ pub struct Accepted {
|
|||
/// `None` when the document has no `third_party`; otherwise what each third-party hop record
|
||||
/// states, in the order of their hops (§6.2).
|
||||
pub third_party: Option<Vec<third_party::Read>>,
|
||||
/// `None` when the document has no `deliveries`; otherwise what each delivery decision states,
|
||||
/// in their order (§6.3).
|
||||
pub deliveries: Option<Vec<delivery::Read>>,
|
||||
}
|
||||
|
||||
/// A verification document's texts (§7.1).
|
||||
|
|
@ -1286,6 +1307,9 @@ pub struct Document<'a> {
|
|||
pub keys: Option<&'a Map<String, Value>>,
|
||||
/// The third-party hop records' texts (§6.2), when the document has them.
|
||||
pub third_party: Option<Vec<&'a str>>,
|
||||
/// The delivery decisions' texts (§6.3), each with its delivery's three documents, when the
|
||||
/// document has them.
|
||||
pub deliveries: Option<Vec<delivery::Texts<'a>>>,
|
||||
}
|
||||
|
||||
impl<'a> Document<'a> {
|
||||
|
|
@ -1296,6 +1320,9 @@ impl<'a> Document<'a> {
|
|||
if v.get("third_party").is_some() {
|
||||
members.push("third_party");
|
||||
}
|
||||
if v.get("deliveries").is_some() {
|
||||
members.push("deliveries");
|
||||
}
|
||||
let o = exactly(v, &members).ok_or_else(bad)?;
|
||||
let s = |k: &str| o[k].as_str().ok_or_else(bad);
|
||||
let receipts = match &o["receipts"] {
|
||||
|
|
@ -1316,7 +1343,12 @@ impl<'a> Document<'a> {
|
|||
Some(Value::Array(a)) if receipts.is_some() => Some(a.iter().map(|x| x.as_str().ok_or_else(bad)).collect::<Result<Vec<_>, _>>()?),
|
||||
Some(_) => return Err(bad()),
|
||||
};
|
||||
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys, third_party })
|
||||
let deliveries = match o.get("deliveries") {
|
||||
None => None,
|
||||
Some(Value::Array(a)) => Some(a.iter().map(|x| delivery::Texts::from_value(x).ok_or_else(bad)).collect::<Result<Vec<_>, _>>()?),
|
||||
Some(_) => return Err(bad()),
|
||||
};
|
||||
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys, third_party, deliveries })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1386,38 +1418,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
|
|||
}
|
||||
// 6. The candidates, the order and the decision.
|
||||
let routed = route(&r, &b, &p);
|
||||
let stated = rec["candidates"].as_array().expect("a list");
|
||||
let cat = |i: usize| at(&[json!("record"), json!("candidates"), json!(i)]);
|
||||
for i in 0..stated.len().max(routed.candidates.len()) {
|
||||
let (Some(s), Some(c)) = (stated.get(i), routed.candidates.get(i)) else {
|
||||
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
|
||||
};
|
||||
let want = c.to_value();
|
||||
let differs_besides = |s: &Value| {
|
||||
let strip = |v: &Value| {
|
||||
let mut o = v.as_object().expect("an object").clone();
|
||||
o.remove("removed");
|
||||
o.remove("rank");
|
||||
o
|
||||
};
|
||||
strip(s) != strip(&want)
|
||||
};
|
||||
if differs_besides(s) {
|
||||
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
|
||||
}
|
||||
if s["removed"] != want["removed"] {
|
||||
return Err(Refusal::new(Reason::RemovalMismatch, cat(i)));
|
||||
}
|
||||
if s["rank"] != want["rank"] {
|
||||
return Err(Refusal::new(Reason::RankMismatch, cat(i)));
|
||||
}
|
||||
}
|
||||
if rec["order"] != json!(routed.order_endpoints()) {
|
||||
return Err(Refusal::new(Reason::RankMismatch, field("order")));
|
||||
}
|
||||
if rec["decision"] != routed.decision() {
|
||||
return Err(Refusal::new(Reason::DecisionMismatch, field("decision")));
|
||||
}
|
||||
check_routed(&rec, &routed, &[json!("record")])?;
|
||||
// 7. The attempts.
|
||||
let attempts = rec["attempts"].as_array().expect("a list");
|
||||
let aat = |i: usize| at(&[json!("record"), json!("attempts"), json!(i)]);
|
||||
|
|
@ -1464,6 +1465,11 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
|
|||
}
|
||||
_ => None,
|
||||
};
|
||||
// 10. The delivery decisions.
|
||||
let deliveries = match &d.deliveries {
|
||||
Some(ds) => Some(delivery::check(&rec, ds)?),
|
||||
None => None,
|
||||
};
|
||||
let served = served_hop.map(|a| (a["endpoint"].as_str().expect("a token").to_string(), a["hop"].as_u64().expect("a hop")));
|
||||
let outputs = served_hop
|
||||
.and_then(|a| a.get("outputs"))
|
||||
|
|
@ -1488,9 +1494,54 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
|
|||
removed: routed.candidates.iter().filter(|c| !c.removed.is_empty()).map(|c| (c.entry.endpoint.clone(), c.removed.iter().map(|(l, _)| *l).collect())).collect(),
|
||||
unmetered,
|
||||
third_party,
|
||||
deliveries,
|
||||
})
|
||||
}
|
||||
|
||||
/// §7.2 step 6 over a routed document, a record or a delivery's decision (§6.3), at `place` and
|
||||
/// the member: its candidates, each in turn, then its order and its decision, as `routed` gives
|
||||
/// them.
|
||||
fn check_routed(rec: &Value, routed: &Routed, place: &[Value]) -> Result<(), Refusal> {
|
||||
let field = |m: &[Value]| {
|
||||
let mut a = place.to_vec();
|
||||
a.extend_from_slice(m);
|
||||
a
|
||||
};
|
||||
let stated = rec["candidates"].as_array().expect("a list");
|
||||
let cat = |i: usize| field(&[json!("candidates"), json!(i)]);
|
||||
for i in 0..stated.len().max(routed.candidates.len()) {
|
||||
let (Some(s), Some(c)) = (stated.get(i), routed.candidates.get(i)) else {
|
||||
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
|
||||
};
|
||||
let want = c.to_value();
|
||||
let differs_besides = |s: &Value| {
|
||||
let strip = |v: &Value| {
|
||||
let mut o = v.as_object().expect("an object").clone();
|
||||
o.remove("removed");
|
||||
o.remove("rank");
|
||||
o
|
||||
};
|
||||
strip(s) != strip(&want)
|
||||
};
|
||||
if differs_besides(s) {
|
||||
return Err(Refusal::new(Reason::CandidateMismatch, cat(i)));
|
||||
}
|
||||
if s["removed"] != want["removed"] {
|
||||
return Err(Refusal::new(Reason::RemovalMismatch, cat(i)));
|
||||
}
|
||||
if s["rank"] != want["rank"] {
|
||||
return Err(Refusal::new(Reason::RankMismatch, cat(i)));
|
||||
}
|
||||
}
|
||||
if rec["order"] != json!(routed.order_endpoints()) {
|
||||
return Err(Refusal::new(Reason::RankMismatch, field(&[json!("order")])));
|
||||
}
|
||||
if rec["decision"] != routed.decision() {
|
||||
return Err(Refusal::new(Reason::DecisionMismatch, field(&[json!("decision")])));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// §6: each attempt held to its hop's OER/2 receipt. The hops whose receipts state the
|
||||
/// unmetered marker.
|
||||
fn check_receipts(rec: &Value, texts: &[&str], keys: &Map<String, Value>) -> Result<Vec<u64>, Refusal> {
|
||||
|
|
@ -1564,6 +1615,9 @@ pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
|
|||
if let Some(t) = &a.third_party {
|
||||
outcome["third_party"] = t.iter().map(third_party::read_json).collect();
|
||||
}
|
||||
if let Some(ds) = &a.deliveries {
|
||||
outcome["deliveries"] = ds.iter().map(delivery::read_json).collect();
|
||||
}
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -764,7 +764,7 @@ fn routed(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], out
|
|||
/// The corpus, as `vectors.json` holds it.
|
||||
pub fn generate() -> Result<String, String> {
|
||||
let mut out = Vec::new();
|
||||
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()).chain(vectors_third_party()) {
|
||||
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()).chain(vectors_third_party()).chain(vectors_delivery()) {
|
||||
let got = outcome_json(&verify(&vec.document));
|
||||
if let Some((reason, at)) = &vec.expect
|
||||
&& (got["outcome"] != "refuse" || got["reason"] != *reason || got["at"] != *at)
|
||||
|
|
@ -1458,3 +1458,292 @@ fn vectors_third_party() -> Vec<Vector> {
|
|||
v.push(output_mismatch("third_party_another_number_than_the_attempts_count", with_third_party(&counted, std::slice::from_ref(&three_record))));
|
||||
v
|
||||
}
|
||||
|
||||
// ───────────────────────────── delivery decisions (§6.3) ─────────────────────────────
|
||||
|
||||
/// What a delivery's routes perform: envelopes named by their content hash, ready for an origin.
|
||||
const EXPORT: &str = "http.export";
|
||||
/// A route's price is per gigabyte.
|
||||
const GB: u64 = 1_000_000_000;
|
||||
|
||||
/// A route of a delivery: one way of carrying a job's output, of entry form of version 2.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn route_entry(endpoint: &str, provider: &str, place: (Option<&str>, Option<&str>), retention: u64, ga: bool, price: u64, ttfb_ms: Option<u64>, failures: Value) -> Value {
|
||||
let mut e = entry(endpoint, provider, IM, place, (Some(false), Some(retention), Some(ga), Some(false)), ("bytes", GB, price), ttfb_ms, None, None);
|
||||
e["operation"] = json!(EXPORT);
|
||||
e["failures"] = failures;
|
||||
e
|
||||
}
|
||||
|
||||
/// The routes. The two `eu-central` caches cost the same and never failed, the second slower;
|
||||
/// `cache.eu-west` is cheaper by its list price and fails a tenth of the time, each failure billed,
|
||||
/// which makes it dearer per delivery served; `origin.eu` is the dearest; `mcast.eu` is a
|
||||
/// preview; and `relay.us` runs in another region, its data resting there.
|
||||
fn routes() -> Vec<Value> {
|
||||
vec![
|
||||
route_entry("cache.eu-central", "prov-cache-a", EU_C, 30, true, 20_000, Some(40), failures(1_000, 0, 0, 0)),
|
||||
route_entry("cache.eu-central-2", "prov-cache-b", EU_C, 30, true, 20_000, Some(80), failures(1_000, 0, 0, 0)),
|
||||
route_entry("cache.eu-west", "prov-cache-c", EU_W, 30, true, 19_000, Some(60), failures(1_000, 100, 19_000, 3_000)),
|
||||
route_entry("mcast.eu", "prov-bearer", EU_W, 0, false, 5_000, None, Value::Null),
|
||||
route_entry("origin.eu", "prov-origin", EU_C, 0, true, 60_000, Some(120), failures(1_000, 0, 0, 0)),
|
||||
route_entry("relay.us", "prov-relay", (Some("us-east"), Some("us")), 0, true, 15_000, Some(90), failures(1_000, 0, 0, 0)),
|
||||
]
|
||||
}
|
||||
|
||||
/// A delivery's request: three gigabytes of a job's output, to `regions` with its data resting in
|
||||
/// `residency`, sorted by price.
|
||||
fn delivery_request(id: &str, regions: &[&str], residency: &[&str], zero_retention: bool) -> Value {
|
||||
json!({
|
||||
"kind": "wai-route-request", "job": id, "operation": EXPORT, "capability": IM, "fallback": null,
|
||||
"quantities": [{"unit": "bytes", "quantity": 3 * GB}],
|
||||
"limits": {
|
||||
"regions": regions, "residency": residency, "no_training": true, "zero_retention": zero_retention, "ga_only": true,
|
||||
"consent_on_file": true, "max_price": [], "spend_ceiling": null,
|
||||
},
|
||||
"prefer": {"sort": "price", "ttfb_ms": null, "quality": null},
|
||||
"deadline_us": null,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_europe(id: &str) -> Value {
|
||||
delivery_request(id, &["eu-central", "eu-west"], &["eu"], false)
|
||||
}
|
||||
|
||||
/// A delivery's documents and its sealed decision.
|
||||
#[derive(Clone)]
|
||||
struct Delivery {
|
||||
request: Value,
|
||||
pricebook: Value,
|
||||
policy: Value,
|
||||
decision: Value,
|
||||
}
|
||||
|
||||
impl Delivery {
|
||||
/// The `seq`-th decision of `parent`'s deliveries, after `prev`, routed over [`routes`] and
|
||||
/// decided `decided` after the job was accepted.
|
||||
fn new(parent: &Job, seq: u64, prev: Option<&Delivery>, request: Value, decided: u64) -> Delivery {
|
||||
Delivery::over(parent, seq, prev, request, pricebook_2_of(routes()), decided)
|
||||
}
|
||||
|
||||
fn over(parent: &Job, seq: u64, prev: Option<&Delivery>, request: Value, pricebook: Value, decided: u64) -> Delivery {
|
||||
let r = Request::read(&canonical(&request)).expect("the request reads");
|
||||
let b = PriceBook::read(&canonical(&pricebook)).expect("the price book reads");
|
||||
let p = Policy::read(&canonical(&policy(3))).expect("the policy reads");
|
||||
let decision = super::delivery::seal(&router(), &parent.record, seq, prev.map(|d| super::digest(&d.decision)), &r, &b, &p, ACCEPTED + decided);
|
||||
Delivery { request, pricebook, policy: policy(3), decision }
|
||||
}
|
||||
|
||||
fn texts(&self) -> Value {
|
||||
json!({"request": canonical(&self.request), "pricebook": canonical(&self.pricebook), "policy": canonical(&self.policy), "decision": canonical(&self.decision)})
|
||||
}
|
||||
|
||||
/// The same delivery with its decision edited and signed again.
|
||||
fn edited(&self, f: impl FnOnce(&mut Value)) -> Delivery {
|
||||
let mut d = self.clone();
|
||||
f(&mut d.decision);
|
||||
d.decision = super::delivery::resign(d.decision, &router());
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
fn with_deliveries(j: &Job, ds: &[&Delivery]) -> Value {
|
||||
let mut d = j.document();
|
||||
d["deliveries"] = json!(ds.iter().map(|x| x.texts()).collect::<Vec<_>>());
|
||||
d
|
||||
}
|
||||
|
||||
fn vectors_delivery() -> Vec<Vector> {
|
||||
let mut v = Vec::new();
|
||||
let at0 = |p: &[Value]| {
|
||||
let mut a = vec![json!("deliveries"), json!(0)];
|
||||
a.extend_from_slice(p);
|
||||
Value::Array(a)
|
||||
};
|
||||
let dec0 = |m: &str| at0(&[json!("decision"), json!(m)]);
|
||||
let alpha = || ("im.alpha", None, 1_000, 4_000_000, metered("im.alpha"));
|
||||
let accept_delivery = |name: &'static str, document: Value| Vector { name, document, expect: None };
|
||||
|
||||
// ── accepted ──
|
||||
// The job served three objects at 4 s; its output goes to Europe at 5 s by the faster of two
|
||||
// caches of one price, and to the United States at 6 s by the one route that keeps no data
|
||||
// there.
|
||||
let job = Job::new_3(request_2("job-5001", ENC, no_preference()), &[alpha()], &delivered("job-5001", 3), true);
|
||||
let eu = Delivery::new(&job, 1, None, to_europe("dlv-5001-eu"), 5_000_000);
|
||||
let us = Delivery::new(&job, 2, Some(&eu), delivery_request("dlv-5001-us", &["us-east"], &["us"], true), 6_000_000);
|
||||
v.push(accept_delivery("delivery_one_route_of_four", with_deliveries(&job, &[&eu])));
|
||||
v.push(accept_delivery("delivery_two_decisions_chained", with_deliveries(&job, &[&eu, &us])));
|
||||
let none = Delivery::new(&job, 1, None, delivery_request("dlv-5001-ap", &["ap-south"], &["ap"], false), 5_000_000);
|
||||
v.push(accept_delivery("delivery_no_route_survives", with_deliveries(&job, &[&none])));
|
||||
let listed = Job::new_2(request_2("job-5002", ENC, no_preference()), pricebook_2(), policy(3), &[alpha()], &delivered("job-5002", 2), true);
|
||||
let listed_eu = Delivery::new(&listed, 1, None, to_europe("dlv-5002-eu"), 5_000_000);
|
||||
v.push(accept_delivery("delivery_under_a_record_of_version_2", with_deliveries(&listed, &[&listed_eu])));
|
||||
let unreceipted = Job::new_3(request_2("job-5001", ENC, no_preference()), &[alpha()], &delivered("job-5001", 3), false);
|
||||
v.push(accept_delivery("delivery_receipts_not_checked", with_deliveries(&unreceipted, &[&eu])));
|
||||
v.push(accept_delivery("delivery_none_given", with_deliveries(&job, &[])));
|
||||
|
||||
// ── refused: the document ──
|
||||
let mut not_a_list = job.document();
|
||||
not_a_list["deliveries"] = Value::Null;
|
||||
v.push(refuse("delivery_not_a_list", not_a_list, "malformed", json!([])));
|
||||
let mut no_policy = with_deliveries(&job, &[&eu]);
|
||||
no_policy["deliveries"][0].as_object_mut().unwrap().remove("policy");
|
||||
v.push(refuse("delivery_an_entry_without_its_policy", no_policy, "malformed", json!([])));
|
||||
let mut not_text = with_deliveries(&job, &[&eu]);
|
||||
not_text["deliveries"][0]["decision"] = eu.decision.clone();
|
||||
v.push(refuse("delivery_a_decision_not_as_text", not_text, "malformed", json!([])));
|
||||
|
||||
// ── refused: the delivery's documents ──
|
||||
let texts = |d: &Delivery, member: &str, f: &dyn Fn(&mut Value)| {
|
||||
let mut doc = with_deliveries(&job, &[d]);
|
||||
let mut x: Value = serde_json::from_str(doc["deliveries"][0][member].as_str().unwrap()).unwrap();
|
||||
f(&mut x);
|
||||
doc["deliveries"][0][member] = json!(canonical(&x));
|
||||
doc
|
||||
};
|
||||
v.push(refuse("delivery_request_invalid", texts(&eu, "request", &|r| r["quantities"] = json!([])), "request_invalid", at0(&[json!("request")])));
|
||||
v.push(refuse(
|
||||
"delivery_pricebook_invalid",
|
||||
texts(&eu, "pricebook", &|b| b["entries"].as_array_mut().unwrap().swap(0, 1)),
|
||||
"pricebook_invalid",
|
||||
at0(&[json!("pricebook")]),
|
||||
));
|
||||
v.push(refuse("delivery_policy_invalid", texts(&eu, "policy", &|p| p["max_attempts"] = json!(0)), "policy_invalid", at0(&[json!("policy")])));
|
||||
|
||||
// ── refused: the decision's form ──
|
||||
let mut not_a_document = with_deliveries(&job, &[&eu]);
|
||||
not_a_document["deliveries"][0]["decision"] = json!("{");
|
||||
v.push(refuse("delivery_decision_not_a_document", not_a_document, "malformed", at0(&[json!("decision")])));
|
||||
let kind = eu.edited(|d| d["kind"] = json!("wai-route-record"));
|
||||
v.push(refuse("delivery_decision_of_another_kind", with_deliveries(&job, &[&kind]), "malformed", at0(&[json!("decision")])));
|
||||
let v2 = eu.edited(|d| d["version"] = json!(2));
|
||||
v.push(refuse("delivery_decision_of_version_2", with_deliveries(&job, &[&v2]), "unknown_version", dec0("version")));
|
||||
let invalid = |name: &'static str, f: &dyn Fn(&mut Value)| refuse(name, with_deliveries(&job, &[&eu.edited(|d| f(d))]), "delivery_invalid", at0(&[json!("decision")]));
|
||||
v.push(invalid("delivery_a_member_missing", &|d| {
|
||||
d.as_object_mut().unwrap().remove("decided");
|
||||
}));
|
||||
v.push(invalid("delivery_a_member_added", &|d| d["attempts"] = json!([])));
|
||||
v.push(invalid("delivery_at_seq_0", &|d| d["seq"] = json!(0)));
|
||||
v.push(invalid("delivery_a_prev_in_capitals", &|d| d["prev"] = json!("AB".repeat(32))));
|
||||
v.push(invalid("delivery_no_object", &|d| d["objects"]["count"] = json!(0)));
|
||||
v.push(invalid("delivery_objects_with_a_member_besides", &|d| d["objects"]["bytes"] = json!(3 * GB)));
|
||||
v.push(invalid("delivery_passed_by_no_key", &|d| d["passed"][0]["key"] = json!("cost")));
|
||||
v.push(invalid("delivery_a_route_as_a_number", &|d| d["route"] = json!(1)));
|
||||
v.push(invalid("delivery_a_candidate_of_version_1", &|d| {
|
||||
let c = d["candidates"][0].as_object_mut().unwrap();
|
||||
for m in ["operation", "failures", "expected_cost", "expected_ttfb_ms"] {
|
||||
c.remove(m);
|
||||
}
|
||||
}));
|
||||
v.push(invalid("delivery_decided_as_text", &|d| d["decided"] = json!("5")));
|
||||
v.push(invalid("delivery_more_passed_than_a_list_holds", &|d| d["passed"] = json!(vec![json!({"endpoint": "origin.eu", "key": "price"}); 1024])));
|
||||
|
||||
// ── refused: its signature ──
|
||||
let mut changed = eu.decision.clone();
|
||||
changed["decided"] = json!(ACCEPTED + 5_000_001);
|
||||
v.push(refuse("delivery_changed_after_it_was_signed", with_deliveries(&job, &[&Delivery { decision: changed, ..eu.clone() }]), "bad_signature", dec0("sig")));
|
||||
let mut small = eu.decision.clone();
|
||||
small["signer"] = json!(hex(&crate::sig::IDENTITY));
|
||||
v.push(refuse("delivery_a_signer_of_small_order", with_deliveries(&job, &[&Delivery { decision: small, ..eu.clone() }]), "bad_key", dec0("sig")));
|
||||
let mut other_domain = eu.decision.clone();
|
||||
let under_the_records = router().sign(&super::signing_bytes(&other_domain));
|
||||
other_domain["sig"] = json!(hex(&under_the_records.to_bytes()));
|
||||
v.push(refuse("delivery_signed_under_the_route_records_domain", with_deliveries(&job, &[&Delivery { decision: other_domain, ..eu.clone() }]), "bad_signature", dec0("sig")));
|
||||
|
||||
// ── refused: its documents ──
|
||||
let edited = |name: &'static str, reason: &'static str, m: &str, f: &dyn Fn(&mut Value)| refuse(name, with_deliveries(&job, &[&eu.edited(|d| f(d))]), reason, dec0(m));
|
||||
v.push(edited("delivery_another_deliverys_id", "request_mismatch", "delivery", &|d| d["delivery"] = json!("dlv-5001-other")));
|
||||
v.push(edited("delivery_another_operation", "request_mismatch", "operation", &|d| d["operation"] = json!("http.load")));
|
||||
v.push(edited("delivery_another_capability", "request_mismatch", "capability", &|d| d["capability"] = json!("wai.video.channel")));
|
||||
v.push(edited("delivery_a_fallback_the_request_does_not_name", "request_mismatch", "fallback", &|d| d["fallback"] = json!("wai.video.channel")));
|
||||
v.push(edited("delivery_its_quantities_restated", "request_mismatch", "quantities", &|d| d["quantities"][0]["quantity"] = json!(2 * GB)));
|
||||
v.push(edited("delivery_another_requests_digest", "request_mismatch", "request", &|d| d["request"] = json!("ab".repeat(32))));
|
||||
v.push(edited("delivery_another_policy", "policy_mismatch", "policy", &|d| d["policy"]["version"] = json!(4)));
|
||||
v.push(edited("delivery_another_policys_name", "policy_mismatch", "policy", &|d| d["policy"]["name"] = json!("fastest")));
|
||||
v.push(edited("delivery_another_policys_digest", "policy_mismatch", "policy", &|d| d["policy"]["digest"] = json!("ab".repeat(32))));
|
||||
v.push(edited("delivery_another_price_book", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["issued"] = json!(ISSUED + 1)));
|
||||
v.push(edited("delivery_another_price_books_digest", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["digest"] = json!("ab".repeat(32))));
|
||||
v.push(edited("delivery_another_currency", "pricebook_mismatch", "pricebook", &|d| d["pricebook"]["currency"] = json!("USD")));
|
||||
let book_1 = pricebook();
|
||||
let on_book_1 = Delivery { pricebook: book_1.clone(), ..eu.clone() }.edited(|d| d["pricebook"] = json!({"digest": hex(&super::digest(&book_1)), "currency": "EUR", "issued": book_1["issued"]}));
|
||||
v.push(refuse("delivery_on_a_price_book_of_version_1", with_deliveries(&job, &[&on_book_1]), "pricebook_mismatch", dec0("pricebook")));
|
||||
|
||||
// ── refused: §4 over them ──
|
||||
let cand = |i: usize| at0(&[json!("decision"), json!("candidates"), json!(i)]);
|
||||
v.push(refuse("delivery_a_routes_price_restated", with_deliveries(&job, &[&eu.edited(|d| d["candidates"][0]["price"] = json!(10_000))]), "candidate_mismatch", cand(0)));
|
||||
v.push(refuse("delivery_a_route_left_out", with_deliveries(&job, &[&eu.edited(|d| { d["candidates"].as_array_mut().unwrap().pop(); })]), "candidate_mismatch", cand(5)));
|
||||
v.push(refuse(
|
||||
"delivery_a_removal_left_out",
|
||||
with_deliveries(&job, &[&eu.edited(|d| { d["candidates"][5]["removed"].as_array_mut().unwrap().pop(); })]),
|
||||
"removal_mismatch",
|
||||
cand(5),
|
||||
));
|
||||
v.push(refuse(
|
||||
"delivery_two_ranks_swapped",
|
||||
with_deliveries(&job, &[&eu.edited(|d| {
|
||||
d["candidates"][0]["rank"] = json!(2);
|
||||
d["candidates"][1]["rank"] = json!(1);
|
||||
})]),
|
||||
"rank_mismatch",
|
||||
cand(0),
|
||||
));
|
||||
v.push(edited("delivery_the_order_reversed", "rank_mismatch", "order", &|d| d["order"].as_array_mut().unwrap().reverse()));
|
||||
v.push(edited("delivery_decided_by_another_key", "decision_mismatch", "decision", &|d| d["decision"]["decided_by"] = json!("price")));
|
||||
|
||||
// ── refused: its route ──
|
||||
v.push(edited("delivery_another_route", "delivery_route_mismatch", "route", &|d| d["route"] = json!("cache.eu-west")));
|
||||
v.push(edited("delivery_no_route_while_one_survives", "delivery_route_mismatch", "route", &|d| d["route"] = Value::Null));
|
||||
v.push(edited("delivery_a_route_passed_by_another_key", "delivery_route_mismatch", "passed", &|d| d["passed"][0]["key"] = json!("price")));
|
||||
v.push(edited("delivery_a_passed_route_left_out", "delivery_route_mismatch", "passed", &|d| { d["passed"].as_array_mut().unwrap().pop(); }));
|
||||
v.push(edited("delivery_a_removed_route_passed", "delivery_route_mismatch", "passed", &|d| d["passed"].as_array_mut().unwrap().push(json!({"endpoint": "relay.us", "key": "price"}))));
|
||||
v.push(refuse(
|
||||
"delivery_a_route_where_none_survives",
|
||||
with_deliveries(&job, &[&none.edited(|d| d["route"] = json!("relay.us"))]),
|
||||
"delivery_route_mismatch",
|
||||
dec0("route"),
|
||||
));
|
||||
|
||||
// ── refused: its parent ──
|
||||
let mut unsigned = eu.decision.clone();
|
||||
let o = unsigned.as_object_mut().unwrap();
|
||||
o.remove("signer");
|
||||
o.remove("sig");
|
||||
let other_router = super::delivery::sign(unsigned, &key("another router"));
|
||||
v.push(refuse("delivery_another_routers", with_deliveries(&job, &[&Delivery { decision: other_router, ..eu.clone() }]), "delivery_parent_mismatch", dec0("signer")));
|
||||
v.push(edited("delivery_another_jobs", "delivery_parent_mismatch", "job", &|d| d["job"] = json!("job-5009")));
|
||||
v.push(edited("delivery_another_parent", "delivery_parent_mismatch", "parent", &|d| d["parent"] = json!(hex(&super::digest(&listed.record)))));
|
||||
v.push(edited("delivery_another_count", "delivery_parent_mismatch", "objects", &|d| d["objects"]["count"] = json!(2)));
|
||||
v.push(edited("delivery_another_root", "delivery_parent_mismatch", "objects", &|d| d["objects"]["root"] = json!(hex(&super::output_commitment(&delivered("job-5001", 2))))));
|
||||
// A parent whose served hop delivered nothing: the decision names it, and its objects are none
|
||||
// the record states.
|
||||
let unserved = Job::new_3(
|
||||
request_2("job-5003", ENC, no_preference()),
|
||||
&[("im.alpha", Some("timeout"), 1_000, 3_000_000, failed("im.alpha")), ("im.eta", Some("unavailable"), 3_000_000, 3_500_000, failed("im.eta")), ("im.iota", Some("refused"), 3_500_000, 3_600_000, failed("im.iota"))],
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
let naming = |parent: &Job| {
|
||||
eu.edited(|d| {
|
||||
d["job"] = parent.record["job"].clone();
|
||||
d["parent"] = json!(hex(&super::digest(&parent.record)));
|
||||
})
|
||||
};
|
||||
v.push(refuse("delivery_of_a_job_no_hop_served", with_deliveries(&unserved, &[&naming(&unserved)]), "delivery_parent_mismatch", dec0("objects")));
|
||||
let first = base();
|
||||
v.push(refuse("delivery_of_a_record_of_version_1", with_deliveries(&first, &[&naming(&first)]), "delivery_parent_mismatch", dec0("objects")));
|
||||
|
||||
// ── refused: its chain ──
|
||||
let at1 = |m: &str| json!(["deliveries", 1, "decision", m]);
|
||||
v.push(refuse("delivery_first_at_seq_2", with_deliveries(&job, &[&eu.edited(|d| d["seq"] = json!(2))]), "delivery_chain_broken", dec0("seq")));
|
||||
v.push(refuse("delivery_first_naming_a_prev", with_deliveries(&job, &[&eu.edited(|d| d["prev"] = json!(hex(&super::digest(&us.decision))))]), "delivery_chain_broken", dec0("prev")));
|
||||
v.push(refuse("delivery_second_with_no_prev", with_deliveries(&job, &[&eu, &us.edited(|d| d["prev"] = Value::Null)]), "delivery_chain_broken", at1("prev")));
|
||||
v.push(refuse("delivery_second_naming_another", with_deliveries(&job, &[&eu, &us.edited(|d| d["prev"] = json!(hex(&super::digest(&none.decision))))]), "delivery_chain_broken", at1("prev")));
|
||||
v.push(refuse("delivery_given_out_of_order", with_deliveries(&job, &[&us, &eu]), "delivery_chain_broken", dec0("seq")));
|
||||
v.push(refuse("delivery_one_left_out", with_deliveries(&job, &[&us]), "delivery_chain_broken", dec0("seq")));
|
||||
let early = Delivery::new(&job, 1, None, to_europe("dlv-5001-eu"), 3_999_999);
|
||||
v.push(refuse("delivery_decided_before_the_output", with_deliveries(&job, &[&early]), "delivery_chain_broken", dec0("decided")));
|
||||
let before = Delivery::new(&job, 2, Some(&eu), delivery_request("dlv-5001-us", &["us-east"], &["us"], true), 4_999_999);
|
||||
v.push(refuse("delivery_decided_before_the_one_before", with_deliveries(&job, &[&eu, &before]), "delivery_chain_broken", at1("decided")));
|
||||
let again = Delivery::new(&job, 2, Some(&eu), to_europe("dlv-5001-eu"), 6_000_000);
|
||||
v.push(refuse("delivery_one_delivery_decided_twice", with_deliveries(&job, &[&eu, &again]), "delivery_chain_broken", at1("delivery")));
|
||||
v
|
||||
}
|
||||
|
|
|
|||
304
wai/wai-rs/src/route_record/delivery.rs
Normal file
304
wai/wai-rs/src/route_record/delivery.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! The delivery-decision record (extensions/route-record.md §6.3): why a delivery of a job's output
|
||||
//! took the route it took.
|
||||
//!
|
||||
//! A delivery is routed as a job is, from its own request, price book (of version 2) and policy,
|
||||
//! by [`super::route`]: the candidates are the routes that could carry it. Its record restates
|
||||
//! every route with the figures its price book stated, every limit that removed one, the order and
|
||||
//! the decision, the route taken, and for every other survivor the key that ranked the route taken
|
||||
//! above it ([`passed`]); and it names its inputs by digest: the delivery's three documents and, as
|
||||
//! its `parent`, the job's route record. Signed by the job's router, it is chained: each decision
|
||||
//! after a job's first names the one before it by `prev`. [`check`] holds a job's decisions, in
|
||||
//! order, to their documents, to §4 over them, to their parent and to their chain (§7.2 step 10).
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
at, by_key, candidate_form, canonical, capability, check_routed, currency, digest, exactly, hex, int, operation, output_commitment, quantities, read_document, route, tok, unhex, Candidate,
|
||||
PriceBook, Policy, Reason, Refusal, Request, Routed, DECIDED_BY, VERSION_2,
|
||||
};
|
||||
|
||||
pub const KIND: &str = "wai-route-delivery-decision";
|
||||
pub const VERSION: u64 = 1;
|
||||
/// The domain a record is signed under (§6.3).
|
||||
pub const SIGN_DOMAIN: &[u8] = b"wai:route-delivery-decision\x01";
|
||||
|
||||
/// The members of a record (§6.3).
|
||||
pub const MEMBERS: [&str; 23] = [
|
||||
"kind", "version", "job", "parent", "seq", "prev", "objects", "delivery", "operation", "capability", "fallback", "quantities", "request", "policy", "pricebook", "candidates",
|
||||
"order", "decision", "route", "passed", "decided", "signer", "sig",
|
||||
];
|
||||
|
||||
/// The keys a survivor may be passed over by: those of a key list (§4.4).
|
||||
pub const PASSED_KEYS: [&str; 6] = ["targets", "price", "ttfb", "energy", "quality", "endpoint"];
|
||||
|
||||
/// A delivery's texts in a verification document (§7.1): its three documents and its decision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Texts<'a> {
|
||||
pub request: &'a str,
|
||||
pub pricebook: &'a str,
|
||||
pub policy: &'a str,
|
||||
pub decision: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Texts<'a> {
|
||||
/// An object of exactly `request`, `pricebook`, `policy` and `decision`, each a string.
|
||||
pub fn from_value(v: &'a Value) -> Option<Texts<'a>> {
|
||||
let o = exactly(v, &["request", "pricebook", "policy", "decision"])?;
|
||||
Some(Texts { request: o["request"].as_str()?, pricebook: o["pricebook"].as_str()?, policy: o["policy"].as_str()?, decision: o["decision"].as_str()? })
|
||||
}
|
||||
}
|
||||
|
||||
/// What a verification reads of a decision that holds (§7.3).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Read {
|
||||
pub seq: u64,
|
||||
pub delivery: String,
|
||||
pub digest: [u8; 32],
|
||||
/// The route the delivery took, or `None` when no route survived.
|
||||
pub route: Option<String>,
|
||||
pub decision: Value,
|
||||
/// Each later survivor's endpoint, with the key it was passed over by.
|
||||
pub passed: Vec<(String, &'static str)>,
|
||||
}
|
||||
|
||||
/// For each survivor after the first, in the order, its endpoint and the first key of the key list
|
||||
/// on which it and the first differ (§6.3 `passed`).
|
||||
pub fn passed(r: &Request, routed: &Routed) -> Vec<(String, &'static str)> {
|
||||
let Some((&first, rest)) = routed.order.split_first() else { return Vec::new() };
|
||||
let a: &Candidate = &routed.candidates[first];
|
||||
rest.iter()
|
||||
.map(|&i| {
|
||||
let b = &routed.candidates[i];
|
||||
let k = routed.key_list.iter().find(|k| by_key(r, k, a, b) != Ordering::Equal).expect("endpoints differ");
|
||||
(b.entry.endpoint.clone(), PASSED_KEYS.iter().copied().find(|x| x == k).expect("a key of the key list"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What a route record's served attempt delivered (§6.3 *its parent*): the count and the
|
||||
/// commitment of its outputs, and its `end`. `None` for a record of version 1, which states no
|
||||
/// output, and for one no hop served.
|
||||
pub fn delivered_by(record: &Value) -> Option<(u64, String, u64)> {
|
||||
let a = record["attempts"].as_array()?.last().filter(|a| a["outcome"] == "served")?;
|
||||
let end = a["end"].as_u64()?;
|
||||
if let Some(d) = a.get("delivered") {
|
||||
return Some((d["count"].as_u64()?, d["root"].as_str()?.to_string(), end));
|
||||
}
|
||||
let hashes = a.get("outputs")?.as_array()?.iter().map(|x| unhex::<32>(x.as_str()?)).collect::<Option<Vec<_>>>()?;
|
||||
Some((hashes.len() as u64, hex(&output_commitment(&hashes)), end))
|
||||
}
|
||||
|
||||
/// The bytes a record is signed over (§6.3): the domain, then the canonical form of the record
|
||||
/// without `sig`.
|
||||
pub fn signing_bytes(record: &Value) -> Vec<u8> {
|
||||
let mut r = record.clone();
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.remove("sig");
|
||||
}
|
||||
let mut m = SIGN_DOMAIN.to_vec();
|
||||
m.extend_from_slice(canonical(&r).as_bytes());
|
||||
m
|
||||
}
|
||||
|
||||
/// Sign a record body with the router's `key`: sets `signer` and `sig`.
|
||||
pub fn sign(mut body: Value, key: &SigningKey) -> Value {
|
||||
let o = body.as_object_mut().expect("a record is an object");
|
||||
o.insert("signer".into(), json!(hex(&key.verifying_key().to_bytes())));
|
||||
o.insert("sig".into(), json!(""));
|
||||
let sig = key.sign(&signing_bytes(&body));
|
||||
body["sig"] = json!(hex(&sig.to_bytes()));
|
||||
body
|
||||
}
|
||||
|
||||
/// Sign a record again over whatever it states, keeping its `signer`: for the corpus's controls.
|
||||
#[doc(hidden)]
|
||||
pub fn resign(mut body: Value, key: &SigningKey) -> Value {
|
||||
body["sig"] = json!("");
|
||||
let sig = key.sign(&signing_bytes(&body));
|
||||
body["sig"] = json!(hex(&sig.to_bytes()));
|
||||
body
|
||||
}
|
||||
|
||||
/// The decision of a delivery of the output of the job whose route record is `parent` (§6.3):
|
||||
/// routed from the delivery's three documents, the `seq`-th of the job's, after the decision whose
|
||||
/// digest is `prev`, decided at `decided`, signed by `key`. `parent` is a record whose served hop
|
||||
/// delivered (version 2 or 3); [`check`] holds the rest.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn seal(key: &SigningKey, parent: &Value, seq: u64, prev: Option<[u8; 32]>, r: &Request, b: &PriceBook, p: &Policy, decided: u64) -> Value {
|
||||
let routed = route(r, b, p);
|
||||
let order = routed.order_endpoints();
|
||||
let (count, root, _) = delivered_by(parent).expect("a record whose served hop delivered");
|
||||
let body = json!({
|
||||
"kind": KIND,
|
||||
"version": VERSION,
|
||||
"job": parent["job"],
|
||||
"parent": hex(&digest(parent)),
|
||||
"seq": seq,
|
||||
"prev": prev.map(|d| hex(&d)),
|
||||
"objects": {"count": count, "root": root},
|
||||
"delivery": r.job,
|
||||
"operation": r.operation,
|
||||
"capability": r.capability,
|
||||
"fallback": r.fallback,
|
||||
"quantities": r.doc["quantities"],
|
||||
"request": hex(&r.digest),
|
||||
"policy": {"name": p.name, "version": p.version, "digest": hex(&p.digest)},
|
||||
"pricebook": {"digest": hex(&b.digest), "currency": b.currency, "issued": b.issued},
|
||||
"candidates": routed.candidates.iter().map(Candidate::to_value).collect::<Vec<_>>(),
|
||||
"order": order,
|
||||
"decision": routed.decision(),
|
||||
"route": order.first(),
|
||||
"passed": passed_json(&passed(r, &routed)),
|
||||
"decided": decided,
|
||||
});
|
||||
sign(body, key)
|
||||
}
|
||||
|
||||
fn passed_json(p: &[(String, &'static str)]) -> Value {
|
||||
Value::Array(p.iter().map(|(e, k)| json!({"endpoint": e, "key": k})).collect())
|
||||
}
|
||||
|
||||
fn hexn(v: &Value, n: usize) -> bool {
|
||||
v.as_str().is_some_and(|s| s.len() == n && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)))
|
||||
}
|
||||
|
||||
/// A record of §6.3's members, each of its own form. Relations between members are §7.2 step 10's.
|
||||
pub fn form(d: &Value) -> bool {
|
||||
let Some(o) = exactly(d, &MEMBERS) else { return false };
|
||||
let tok_or_null = |v: &Value| v.is_null() || tok(v).is_some();
|
||||
o["version"] == json!(VERSION)
|
||||
&& tok(&o["job"]).is_some()
|
||||
&& hexn(&o["parent"], 64)
|
||||
&& int(&o["seq"]).is_some_and(|n| n >= 1)
|
||||
&& (o["prev"].is_null() || hexn(&o["prev"], 64))
|
||||
&& exactly(&o["objects"], &["count", "root"]).is_some_and(|x| int(&x["count"]).is_some_and(|n| n >= 1) && hexn(&x["root"], 64))
|
||||
&& tok(&o["delivery"]).is_some()
|
||||
&& o["operation"].as_str().is_some_and(operation)
|
||||
&& o["capability"].as_str().is_some_and(capability)
|
||||
&& (o["fallback"].is_null() || o["fallback"].as_str().is_some_and(capability))
|
||||
&& quantities(&o["quantities"]).is_some()
|
||||
&& hexn(&o["request"], 64)
|
||||
&& exactly(&o["policy"], &["name", "version", "digest"]).is_some_and(|p| tok(&p["name"]).is_some() && int(&p["version"]).is_some_and(|v| v >= 1) && hexn(&p["digest"], 64))
|
||||
&& exactly(&o["pricebook"], &["digest", "currency", "issued"])
|
||||
.is_some_and(|p| hexn(&p["digest"], 64) && p["currency"].as_str().is_some_and(currency) && int(&p["issued"]).is_some())
|
||||
&& o["candidates"].as_array().is_some_and(|c| c.len() <= 1024 && c.iter().all(|c| candidate_form(c, VERSION_2)))
|
||||
&& o["order"].as_array().is_some_and(|x| x.len() <= 1024 && x.iter().all(|e| tok(e).is_some()))
|
||||
&& exactly(&o["decision"], &["decided_by", "runner_up"]).is_some_and(|d| d["decided_by"].as_str().is_some_and(|x| DECIDED_BY.contains(&x)) && tok_or_null(&d["runner_up"]))
|
||||
&& tok_or_null(&o["route"])
|
||||
&& o["passed"].as_array().is_some_and(|ps| {
|
||||
ps.len() <= 1023 && ps.iter().all(|x| exactly(x, &["endpoint", "key"]).is_some_and(|x| tok(&x["endpoint"]).is_some() && x["key"].as_str().is_some_and(|k| PASSED_KEYS.contains(&k))))
|
||||
})
|
||||
&& int(&o["decided"]).is_some()
|
||||
&& hexn(&o["signer"], 64)
|
||||
&& hexn(&o["sig"], 128)
|
||||
}
|
||||
|
||||
/// §7.2 step 10: a job's decisions, from the first and in order, each read, its signature checked
|
||||
/// and held to its documents, to §4 over them, to its parent `rec` and to its chain.
|
||||
pub(super) fn check(rec: &Value, deliveries: &[Texts<'_>]) -> Result<Vec<Read>, Refusal> {
|
||||
let parent = delivered_by(rec);
|
||||
let parent_digest = hex(&digest(rec));
|
||||
let mut read: Vec<Read> = Vec::new();
|
||||
let mut before: Option<(String, u64)> = None;
|
||||
let mut seen = BTreeSet::new();
|
||||
for (i, t) in deliveries.iter().enumerate() {
|
||||
let place = |m: &[Value]| {
|
||||
let mut a = vec![json!("deliveries"), json!(i)];
|
||||
a.extend_from_slice(m);
|
||||
at(&a)
|
||||
};
|
||||
let member = |m: &str| place(&[json!("decision"), json!(m)]);
|
||||
let r = Request::read(t.request).map_err(|e| Refusal::new(e, place(&[json!("request")])))?;
|
||||
let b = PriceBook::read(t.pricebook).map_err(|e| Refusal::new(e, place(&[json!("pricebook")])))?;
|
||||
let p = Policy::read(t.policy).map_err(|e| Refusal::new(e, place(&[json!("policy")])))?;
|
||||
let d = read_document(t.decision).ok_or_else(|| Refusal::new(Reason::Malformed, place(&[json!("decision")])))?;
|
||||
if d.get("kind").and_then(Value::as_str) != Some(KIND) {
|
||||
return Err(Refusal::new(Reason::Malformed, place(&[json!("decision")])));
|
||||
}
|
||||
if d.get("version").and_then(int).is_some_and(|v| v != VERSION) {
|
||||
return Err(Refusal::new(Reason::UnknownVersion, member("version")));
|
||||
}
|
||||
if !form(&d) {
|
||||
return Err(Refusal::new(Reason::DeliveryInvalid, place(&[json!("decision")])));
|
||||
}
|
||||
let signer: [u8; 32] = unhex(d["signer"].as_str().expect("a key")).expect("a key");
|
||||
let sig: [u8; 64] = unhex(d["sig"].as_str().expect("a signature")).expect("a signature");
|
||||
crate::sig::verify_strict(&signer, &signing_bytes(&d), &sig).map_err(|e| {
|
||||
let reason = match e {
|
||||
crate::sig::SigError::BadKey => Reason::BadKey,
|
||||
crate::sig::SigError::BadSignature => Reason::BadSignature,
|
||||
};
|
||||
Refusal::new(reason, member("sig"))
|
||||
})?;
|
||||
// Its documents.
|
||||
for (m, want) in [
|
||||
("delivery", json!(r.job)),
|
||||
("operation", json!(r.operation)),
|
||||
("capability", json!(r.capability)),
|
||||
("fallback", json!(r.fallback)),
|
||||
("quantities", r.doc["quantities"].clone()),
|
||||
("request", json!(hex(&r.digest))),
|
||||
] {
|
||||
if d[m] != want {
|
||||
return Err(Refusal::new(Reason::RequestMismatch, member(m)));
|
||||
}
|
||||
}
|
||||
if d["policy"] != json!({"name": p.name, "version": p.version, "digest": hex(&p.digest)}) {
|
||||
return Err(Refusal::new(Reason::PolicyMismatch, member("policy")));
|
||||
}
|
||||
if b.version != VERSION_2 || d["pricebook"] != json!({"digest": hex(&b.digest), "currency": b.currency, "issued": b.issued}) {
|
||||
return Err(Refusal::new(Reason::PricebookMismatch, member("pricebook")));
|
||||
}
|
||||
// §4 over them, then the route.
|
||||
let routed = route(&r, &b, &p);
|
||||
check_routed(&d, &routed, &place(&[json!("decision")]))?;
|
||||
let order = routed.order_endpoints();
|
||||
if d["route"] != json!(order.first()) {
|
||||
return Err(Refusal::new(Reason::DeliveryRouteMismatch, member("route")));
|
||||
}
|
||||
let why = passed(&r, &routed);
|
||||
if d["passed"] != passed_json(&why) {
|
||||
return Err(Refusal::new(Reason::DeliveryRouteMismatch, member("passed")));
|
||||
}
|
||||
// Its parent.
|
||||
for m in ["signer", "job"] {
|
||||
if d[m] != rec[m] {
|
||||
return Err(Refusal::new(Reason::DeliveryParentMismatch, member(m)));
|
||||
}
|
||||
}
|
||||
if d["parent"] != json!(parent_digest) {
|
||||
return Err(Refusal::new(Reason::DeliveryParentMismatch, member("parent")));
|
||||
}
|
||||
let Some((count, root, served_end)) = &parent else { return Err(Refusal::new(Reason::DeliveryParentMismatch, member("objects"))) };
|
||||
if d["objects"] != json!({"count": count, "root": root}) {
|
||||
return Err(Refusal::new(Reason::DeliveryParentMismatch, member("objects")));
|
||||
}
|
||||
// Its chain.
|
||||
if d["seq"] != json!(i + 1) {
|
||||
return Err(Refusal::new(Reason::DeliveryChainBroken, member("seq")));
|
||||
}
|
||||
if d["prev"] != json!(before.as_ref().map(|(h, _)| h)) {
|
||||
return Err(Refusal::new(Reason::DeliveryChainBroken, member("prev")));
|
||||
}
|
||||
let decided = d["decided"].as_u64().expect("a time");
|
||||
if decided < *served_end || before.as_ref().is_some_and(|(_, t)| decided < *t) {
|
||||
return Err(Refusal::new(Reason::DeliveryChainBroken, member("decided")));
|
||||
}
|
||||
if !seen.insert(r.job.clone()) {
|
||||
return Err(Refusal::new(Reason::DeliveryChainBroken, member("delivery")));
|
||||
}
|
||||
let dg = digest(&d);
|
||||
before = Some((hex(&dg), decided));
|
||||
read.push(Read { seq: i as u64 + 1, delivery: r.job.clone(), digest: dg, route: order.first().cloned(), decision: routed.decision(), passed: why });
|
||||
}
|
||||
Ok(read)
|
||||
}
|
||||
|
||||
/// A read decision's outcome (§7.3).
|
||||
pub fn read_json(r: &Read) -> Value {
|
||||
json!({"seq": r.seq, "delivery": r.delivery, "digest": hex(&r.digest), "route": r.route, "decision": r.decision, "passed": passed_json(&r.passed)})
|
||||
}
|
||||
Loading…
Reference in a new issue