joule-moq: draft-ietf-moq-transport-22 wire codec, with the crate moved onto it #283

Merged
dcharlot merged 7 commits from joule-moq/draft-22 into main 2026-10-17 04:04:12 -04:00
35 changed files with 10833 additions and 1018 deletions

View file

@ -105,6 +105,25 @@ jobs:
working-directory: joule-code/joule-code-rs
run: cargo test -p joule-moq --features quic --all-targets
# joule-moq's draft-ietf-moq-transport-22 vectors, read by a second decoder
# written from the draft's text rather than from the crate. The step above
# (tests/draft22_vectors.rs) holds the crate to the same corpus and fails if
# the committed corpus is not what its generator makes. This one must read
# every good vector as stated and refuse every negative one as stated; it
# fails on an empty corpus.
- name: Ensure python3 is available
shell: bash
run: |
if ! command -v python3 >/dev/null 2>&1; then
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq python3
fi
python3 --version
- name: joule-moq draft-22 vectors, independent decoder
run: python3 -I joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py
- name: Round-trip the v1 conformance pack (C1-C4)
working-directory: joule-code/joule-code-rs
run: |

View file

@ -18,6 +18,45 @@ All notable changes to the Joule Code standard are recorded here. The format fol
- **§6 / `spec/receipt.md` §2 — a vendor counter is a hardware reading only while it is demonstrably live.** A reading from a vendor energy counter (RAPL, NVML, Apple SMC/IOReport) MAY be tagged `HwShunt`, and supply `measured_uj`, only while the counter has been observed advancing under load for the domain the figure claims (for IOReport: the CPU energy rail itself, in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms of CPU burn). A counter that is present but not live — frozen, or republished in lumps — MUST NOT produce either; the reading falls back to the estimator. A figure MUST NOT claim coverage of a domain its counter does not read. No wire or conformance-vector change.
### Changed — `joule-moq` 0.3.0: `draft-ietf-moq-transport-22`
`joule-moq` speaks `draft-ietf-moq-transport-22` (1 October 2026), negotiated by
the ALPN `moqt-22`, in place of its draft-08-style codec. No byte of the old wire
form survives: -22 changed the varint, the control streams, every message and the
object framing. The crate's version moves to 0.3.0 because its public API changes:
- `draft22`: the codec of -22, a module of its own selected through `draft::Draft`,
so another draft can be added beside it. Every control message and parameter,
Key-Value-Pairs, Properties, Setup Options, the Authorization Token, Request IDs
and request-stream rules, subgroup streams, datagrams, fetch streams and padding,
and the status and error codes. Every refusal is a typed `WireError` carrying the
draft's consequence; `MoqError::Wire` wraps it.
- `MOQT_DRAFT_VERSION` is `0xff000016`. `transport_quic::ALPN_MOQT` is `moqt-22`.
- `messages` re-exports -22's control messages. `SetupMessage`, `AnnounceMessage`,
`SubscribeMessage`, `SubscribeFilter` and the draft-08 `ControlMessage` are gone.
SUBSCRIBE's `max_mrl_layer` is an OBJECT_PROPERTY_FILTER on the `MRL_LAYER`
property (`messages::mrl_cap_filter`); `require_receipts` is the subscriber's own
policy, met by subscribing to a track's receipts track.
- `carriage` (new): the crate's objects ride -22 subgroup objects, with the object
receipt, the `WAI_RECEIPT` property of WAI `extensions/moq-streaming-format.md`
§5 and the layer fields as properties in an application range; a group's
receipts ride its receipts track (§4.1). `ReceiptBundle` moved here from `mock`,
which re-exports it.
- `session`: the draft by ALPN (`MoqSession::select_alpn`); SETUP carries options
and no version or role, and `build_setup` takes no label; Request IDs by end.
- `transport_quic`: a pair of unidirectional control streams opened by SETUP, a
request stream per request, subgroup, fetch and padding streams, and datagrams.
`ControlStream`, `write_object_to_uni`, `read_object_from_uni`,
`write_group_receipt_to_uni` and `read_group_receipt_from_uni` are replaced by
`QuicSession::{open_control, accept_control, open_request, accept_request,
open_subgroup, open_fetch, accept_data}`, `send_object`, `send_receipt_bundle`
and `DataReceiver`.
- `wire::encode_varint` and `decode_varint` are -22's integer; every `u64` encodes.
- `mock`: objects, receipt bundles and requests cross the -22 codec; the relay
reads an object's delivery timeout from its OBJECT_DELIVERY_TIMEOUT property.
- `tests/vectors/draft-22/`: a corpus of -22 vectors, good and negative, generated
by `tests/draft22_vectors.rs`, and an independent decoder that checks it in CI.
### Changed — `joule-moq` 0.2.0: WAI's labelled JWP forms, and a strict verifier
`joule-moq` reads and writes the forms of WAI's `extensions/jwp-receipts.md`

View file

@ -654,7 +654,7 @@ dependencies = [
[[package]]
name = "joule-moq"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-trait",
"blake3",

View file

@ -1368,7 +1368,7 @@ dependencies = [
[[package]]
name = "joule-moq"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"async-trait",
"blake3",

View file

@ -1,7 +1,7 @@
[package]
name = "joule-moq"
version = "0.2.0"
description = "MoQT (Media over QUIC Transport) wire protocol + JWP receipt extension for CommunicationOS broadcast media. Trait surface lets the Realtime stack publish, subscribe, and relay tracks with object-level signed provenance and joule receipts."
version = "0.3.0"
description = "MoQT (Media over QUIC Transport, draft-ietf-moq-transport-22) wire protocol + JWP receipt extension for CommunicationOS broadcast media. Trait surface lets the Realtime stack publish, subscribe, and relay tracks with object-level signed provenance and joule receipts."
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"

View file

@ -0,0 +1,171 @@
# joule-moq
MoqT (Media over QUIC Transport) for CommunicationOS broadcast media, with a JWP
receipt extension: every object carries verifiable provenance and its energy
figure.
## The draft it implements
`draft-ietf-moq-transport-22` (1 October 2026), and no other. The draft is chosen
before the first MOQT byte, by the ALPN `moqt-22` on native QUIC or the
`WT-Available-Protocols` value `moqt-22` on WebTransport (§6.2); `SETUP` carries no
version. `MOQT_DRAFT_VERSION` is the conventional code `0xff000016`.
Drafts do not interoperate with each other. Deployed relays speak earlier drafts,
which differ from -22 in their varint, their control streams and their messages, so
this crate does not talk to them. The codec of -22 is a module of its own
(`src/draft22/`), selected through `draft::Draft`: a later draft, or an earlier one
added for interoperation, is a sibling module and a `Draft` variant, and the
draft-neutral parts (tracks, objects, receipts, the session's state) stay as they are.
## What is implemented
The codec (`draft22`), every decoder bounded (it reads no byte that is not there and
allocates nothing from a length before the bytes behind it exist) and every refusal a
typed `WireError` carrying the draft's consequence: close the session with a code,
reject the request, or a malformed track.
| -22 | module |
|---|---|
| §8.1 variable-length integer: 1 to 9 bytes, leading ones, longer forms accepted | `draft22::varint` |
| §8.2 Location, §8.7 Track Namespace (at most 32 fields, none empty, 4,096 bytes for a Full Track Name), §8.8 rendering | `draft22::names` |
| §8.3 Key-Value-Pairs (delta types, values up to 2^16-1 bytes), §8.4 and §10 Properties with Immutable Properties, Prior Group and Object ID Gaps and Mandatory Track Properties, §9.1 Setup Options | `draft22::kvp` |
| §8.9 Authorization Token structure | `draft22::token` |
| §9 every control message of Table 5, framed by a 16-bit Length | `draft22::control` |
| §9.20 every Message Parameter, the list each message allows, the rules on repetition; §8.6 Range Filters; §9.20.9 Location Filters; FILL_PARAMETERS | `draft22::params` |
| §6.3 and §6.4.2 control and request streams, Request IDs by end, §9.1.7 MAX_REQUEST_UPDATES credit, the order of messages on each request stream | `draft22::request` |
| §11 subgroup streams, object datagrams, fetch streams with End of Range, padding; a whole-stream decoder and an incremental one | `draft22::data` |
| §12 and §16.11 session, request, PUBLISH_DONE and stream reset codes; §13 grease | `draft22::codes` |
On it:
- `session`: the draft by ALPN, SETUP options, Request IDs, the receipt extension
declared by a Setup Option.
- `transport_quic` (feature `quic`): native QUIC on quinn. A pair of unidirectional
control streams, each opened by SETUP; a bidirectional stream per request; subgroup,
fetch and padding streams; object datagrams.
- `mock`: an in-memory fabric and relay whose objects, receipts and requests cross
the -22 codec.
- `carriage`: how this crate's objects and receipts ride -22 objects (below).
### Where -22 leaves an encoding open
The codec takes one reading of each, and the vector checker takes the same:
- FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04) state no encoding; both are
durations in milliseconds and are read as varints, as the draft encodes its other
durations.
- FILL_PARAMETERS is length-prefixed and holds "a sequence of Parameters": read up to
its length, with no count before them, type deltas starting from 0.
- End of Range (§11.4.1.2) says "the Group ID and Object ID fields are present"; the
only such fields of a fetch object are the Group ID Delta and Object ID Delta,
which bits 0x08 and 0x04 of 0x8C, 0x10C and 0x20C mark. They are read as an
object's are: absolute on the first entry, and after it a Group ID that moves one
or more groups in the request's Group Order, with an absolute Object ID.
- An unknown message, stream or datagram type closes the session; -22 names no code,
and this codec gives PROTOCOL_VIOLATION.
## Carriage of this crate's objects and receipts
Object Properties in the range -22 sets aside for an application's own use (§8.4),
inside the object's Immutable Properties, since a relay must not change them:
| type | name | value |
|---|---|---|
| `0x3A40` | `MRL_LAYER` | varint: the object's layer, when the header states layers |
| `0x3A41` | `WAI_RECEIPT` | bytes: WAI `extensions/moq-streaming-format.md` §5's property, laid out below |
| `0x3A42` | `MRL_TOTAL_LAYERS` | varint, when non-zero |
| `0x3A43` | `JWP_OBJECT_RECEIPT` | bytes: the object receipt, as this crate serialises it to JSON |
| `0x3A44` | `WAI_ENVELOPE` | varint `1`, when the payload is a WAI envelope |
A layer cap on a SUBSCRIBE is an OBJECT_PROPERTY_FILTER on `MRL_LAYER`
(`messages::mrl_cap_filter`), which needs the peer's MAX_FILTER_RANGES above 0. An
object's delivery timeout rides as its OBJECT_DELIVERY_TIMEOUT property (§10.2).
The `WAI_RECEIPT` value: `content_hash` (32 bytes), `joules_micro` (a -22 varint),
`acquisition` (a varint length, then UTF-8), `acquisition_evidence` (a varint length,
then bytes), `group_receipt` (32 bytes), and nothing after.
A group's receipts ride its receipts track (moq-streaming-format §4.1): group *g*
carries one object, a JSON array of the group receipt and the object receipts of
group *g* of the media track.
The receipt extension is declared by the Setup Option `0x3A46`, a varint holding the
extension's version. -22 has no unregistered range for Setup Options and asks for a
provisional registration; until one is made the code point is this crate's choice,
and a peer that does not know it ignores it (§9.1).
## Not yet implemented
- WebTransport (MOQT over HTTP/3, §6.2.1), a relay with priority fan-out and
per-object receipts, and a browser client: the next slice builds them on this codec.
- The authorization token cache (§8.9: aliases, MAX_AUTH_TOKEN_CACHE_SIZE,
retirement): the codec reads and writes tokens, and the session keeps no cache.
- Evaluating filters at a publisher (§3.3), and holding a peer to its
MAX_FILTER_RANGES: the codec reads and writes every filter and counts their ranges.
- GOAWAY migration, delivery-timeout enforcement on real streams, and §5.1's
scheduling in the QUIC transport; the mock relay applies delivery timeouts.
- `draft-ietf-moq-loc` and `draft-ietf-moq-msf` semantics (catalogs, LOC
properties): their properties ride as any property does.
- The receipts-track payload in the JSON form of WAI `extensions/jwp-receipts.md`
§2.4.1 and §2.4.2: today it is this crate's own JSON of the same receipts.
## The vector corpus
`tests/vectors/draft-22/vectors.json` holds -22 vectors: varints, every control
message type, control, subgroup, fetch and padding streams, datagrams, and the
`WAI_RECEIPT` value; and negative vectors, each with the refusal it must meet.
`tests/draft22_vectors.rs` generates the file deterministically from typed values
and holds the crate to it; `JOULE_MOQ_WRITE_VECTORS=1` rewrites it.
`tests/vectors/draft-22/moqt22_check.py` decodes the corpus independently, from the
draft's text, standard library only:
```sh
python3 -I tests/vectors/draft-22/moqt22_check.py
```
Each vector has a `name`, a `kind`, its bytes as `hex`, `canonical` (whether this
crate's encoder makes exactly those bytes) and `expect`; a `uni-stream` vector also
has `context.group_order` (1 Ascending, 2 Descending), the Group Order of the request
a fetch stream answers.
| kind | bytes | reading |
|---|---|---|
| `varint` | one varint | `{"value", "length"}` |
| `control` | one framed message | the message (below) |
| `uni-stream` | a whole stream to its FIN | a list of events (below) |
| `datagram` | one datagram | `{"datagram": {…}}` or `{"padding": n}` |
| `wai-receipt` | the `WAI_RECEIPT` value | its five fields |
The JSON view: byte strings are lowercase hex; a Track Namespace is a list of its
fields in hex; a Location is `[group, object]`; a Key-Value-Pair is `{"type", "value"}`
for an even type and `{"type", "bytes"}` for an odd one, with Immutable Properties as
raw bytes. A message is an object with `"message"` (its name in Table 5) and its
fields by their names in the draft, lowercased with underscores. A parameter is
`{"type": NAME}` with `"value"` (a varint or a uint8), `"token"`, `"location"`,
`"filter"` (a Location Filter's `filter_type` and the fields it has; a Range Filter's
`set_id`, `property_type` for the property filters, and `ranges` as `[start, end or
null]`; `null` for Length 0), `"parameters"` (FILL_PARAMETERS) or `"namespace"`.
Stream events are `{"header": "subgroup" | "fetch" | "control" | "padding", …}`,
`{"control": message}`, `{"object": …}` (subgroup), `{"fetch_object": …}`,
`{"end_of_range": {"kind", "location"}}` and `{"padding": n}`.
A refusal is `{"error": kind, "consequence": c}`, with `c` one of
`session:<code>`, `request:<code>` or `malformed-track` (and `property_invalid` for
the `WAI_RECEIPT` value, which a sink discards). The kinds are `WireError::kind`:
`truncated`, `trailing-bytes`, `unknown-message-type`, `unknown-stream-type`,
`invalid-flags`, `unknown-parameter`, `duplicate`, `parameter-not-allowed`,
`duplicate-filter`, `invalid-filter`, `type-overflow`, `length-exceeds-limit`,
`too-many-namespace-fields`, `empty-namespace-field`, `invalid-value`, `overflow`,
`properties-on-non-normal`, `unknown-object-status`, `no-prior-object`,
`empty-datagram-properties`, `malformed-token`, `unexpected-message`,
`malformed-track`.
## Tests
- `cargo test -p joule-moq --features quic --all-targets` runs the codec's unit
tests, the corpus (`tests/draft22_vectors.rs`), generated round trips and hostile
input (`tests/draft22_roundtrip.rs`), the QUIC transport over loopback, the mock
and relay, the staged MoQ trace and WAI's JWP corpus.
- The independent decoder above runs over the corpus in CI
(`.github/workflows/joule-code-conformance.yml`).

View file

@ -1,6 +1,9 @@
//! Cross-machine MoQT-over-QUIC demo: a publisher streams signed,
//! receipt-bearing objects; a subscriber on another host verifies every object
//! against the publisher's pinned Ed25519 key over a real QUIC connection.
//! Cross-machine MoQT-over-QUIC demo (`draft-ietf-moq-transport-22`, ALPN
//! `moqt-22`): a publisher streams signed, receipt-bearing objects; a subscriber
//! on another host verifies every object against the publisher's pinned Ed25519
//! key over a real QUIC connection. The group's receipts ride its receipts track
//! as one subgroup stream, the objects the media track as another, each object's
//! receipt in its properties.
//!
//! Build: cargo build --example quic_xnet --features quic
//! Publish (host A):
@ -48,16 +51,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
use joule_moq::messages::ObjectHeader;
use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier};
use joule_moq::track::{GroupId, ObjectStatus, TrackAlias};
use joule_moq::transport_quic::{
generate_self_signed, read_group_receipt_from_uni, read_object_from_uni,
write_group_receipt_to_uni, write_object_to_uni, QuicEndpoint,
};
use joule_moq::carriage::{ObjectExtras, ReceiptBundle};
use joule_moq::draft22::{StreamHeader, SubgroupHeader, SubgroupId};
use joule_moq::transport_quic::{generate_self_signed, send_object, send_receipt_bundle, QuicEndpoint};
use rand_core_06::OsRng;
use rustls::pki_types::CertificateDer;
let args: Vec<String> = std::env::args().collect();
let mode = args.get(1).map(|s| s.as_str()).unwrap_or("");
let track_alias = TrackAlias(7);
let receipts_alias = 8u64;
let group_id = GroupId(1);
let origin = "did:key:zJouleMoqPublisher";
@ -98,8 +101,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let group_end = chrono::Utc::now().to_rfc3339();
let group_receipt = builder.finalize(&signing_key, "joule-moq-xnet", group_end);
let mut uni = session.open_uni().await?;
write_group_receipt_to_uni(&mut uni, &group_receipt).await?;
let mut sent = Vec::new();
for ((id, payload, hash, joules), proof) in pending.into_iter().zip(proofs) {
let header = ObjectHeader {
track_alias,
@ -122,9 +124,25 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
merkle_proof: proof,
energy_class: None,
};
write_object_to_uni(&mut uni, &header, Some(&receipt), &payload).await?;
sent.push((header, payload, receipt));
}
uni.finish()?;
let bundle = ReceiptBundle { group: group_receipt.clone(), objects: sent.iter().map(|s| s.2.clone()).collect() };
send_receipt_bundle(&session, receipts_alias, group_id.0, 128, &bundle).await?;
let mut media = session
.open_subgroup(SubgroupHeader {
track_alias: track_alias.0,
group_id: group_id.0,
subgroup_id: SubgroupId::Zero,
publisher_priority: Some(128),
properties: true,
end_of_group: true,
first_object: true,
})
.await?;
for (header, payload, receipt) in sent {
send_object(&mut media, &header, &payload, &ObjectExtras { receipt: Some(receipt), wai_receipt: None, delivery_timeout_ms: None }).await?;
}
media.finish()?;
println!("publisher: streamed group receipt + {} objects", group_receipt.leaf_count);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
println!("publisher: done");
@ -145,8 +163,20 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let session = client.connect(connect, "joule-pub").await?;
println!("subscriber: connected to {connect}");
let mut uni = session.accept_uni().await?;
let group_receipt = read_group_receipt_from_uni(&mut uni).await?;
let mut receipts = None;
let mut objects = Vec::new();
for _ in 0..2 {
let mut rx = session.accept_data().await?;
let StreamHeader::Subgroup(h) = rx.header else { return Err("expected a subgroup stream".into()) };
if h.track_alias == receipts_alias {
receipts = Some(rx.read_receipt_bundle().await?);
} else {
while let Some(o) = rx.next_object().await? {
objects.push(o);
}
}
}
let group_receipt = receipts.ok_or("no receipts track")?.group;
let verifier = ReceiptVerifier::with_anchors(vec![publisher_key]);
let root = verifier.verify_group(&group_receipt)?;
@ -157,9 +187,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
hex_encode(&root.root[..8]),
);
for i in 0..group_receipt.leaf_count {
let (_header, receipt, payload) = read_object_from_uni(&mut uni).await?;
let receipt = receipt.ok_or("object missing its receipt")?;
for (i, (_header, payload, extras)) in objects.into_iter().enumerate() {
let receipt = extras.receipt.ok_or("object missing its receipt")?;
verifier.verify_payload(&payload, &receipt, &root)?;
println!(
"subscriber: object {i} VERIFIED — {} ({} µJ)",

View file

@ -0,0 +1,477 @@
//! How this crate's objects and receipts ride MOQT -22.
//!
//! The crate's data model is draft-neutral: an [`ObjectHeader`], a payload, an
//! [`ObjectReceipt`]. This module maps it onto -22 objects:
//!
//! - **Object Properties** in the range -22 sets aside for an application's own
//! use without registration (`0x3800`-`0x3FFF`, §8.4), all inside the
//! object's Immutable Properties (§10.7), since a relay must not change them:
//!
//! | type | name | value |
//! |---|---|---|
//! | `0x3A40` | `MRL_LAYER` | varint: [`ObjectHeader::mrl_layer`], when the header states layers |
//! | `0x3A41` | `WAI_RECEIPT` | bytes: the property of WAI `extensions/moq-streaming-format.md` §5, laid out as [`WaiReceiptProperty::to_bytes`] says |
//! | `0x3A42` | `MRL_TOTAL_LAYERS` | varint: [`ObjectHeader::mrl_total_layers`], when non-zero |
//! | `0x3A43` | `JWP_OBJECT_RECEIPT` | bytes: the object's receipt as JSON (this crate's serialisation of [`ObjectReceipt`]); WAI `extensions/jwp-receipts.md` §1 lets a receipt ride "a MoQT object header field" |
//! | `0x3A44` | `WAI_ENVELOPE` | varint `1`: [`ObjectHeader::wai_envelope`], when set |
//!
//! An even type is a varint and an odd one bytes, as §8.3 requires. Another
//! application may give these code points other meanings (§8.4); a sink
//! reads them only on tracks it knows carry this crate's objects.
//! - **A receipts track** (moq-streaming-format §4.1): group *g* of it carries
//! one object whose payload is a JSON array of the group receipt and the
//! object receipts of group *g* of its media track ([`ReceiptBundle`]).
//! - **A Setup Option**, [`SETUP_OPTION_JWP_RECEIPTS`], by which an endpoint
//! declares the JWP receipt extension and its version (§6.3.2). -22 has no
//! unregistered range for Setup Options and asks for a provisional
//! registration; until one is made this code point is this crate's choice,
//! and a peer that does not know it ignores it (§9.1).
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use crate::draft22::data::{ObjectStatus as WireStatus, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject};
use crate::draft22::io::{put_varint, Reader};
use crate::draft22::kvp::{prop as wire_prop, KeyValuePair, Properties};
use crate::draft22::{decode_uni_stream, GroupOrder, StreamHeader, UniEvent, WireError};
use crate::error::{MoqError, MoqResult};
use crate::messages::ObjectHeader;
use crate::receipt::{check_class, GroupReceipt, ObjectReceipt};
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias};
/// The object property types this crate defines (see the module documentation).
pub mod prop {
pub const MRL_LAYER: u64 = 0x3a40;
pub const WAI_RECEIPT: u64 = 0x3a41;
pub const MRL_TOTAL_LAYERS: u64 = 0x3a42;
pub const JWP_OBJECT_RECEIPT: u64 = 0x3a43;
pub const WAI_ENVELOPE: u64 = 0x3a44;
}
/// The Setup Option that declares the JWP receipt extension; its value (a
/// varint, the type being even) is [`crate::JWP_MOQ_EXTENSION_VERSION`].
pub const SETUP_OPTION_JWP_RECEIPTS: u64 = 0x3a46;
/// The priority an object takes when its subgroup gives none and the track no
/// DEFAULT_PUBLISHER_PRIORITY (§10.4: 128).
pub const DEFAULT_PRIORITY: u8 = 128;
// ── WAI_RECEIPT ─────────────────────────────────────────────────────────────
/// The `WAI_RECEIPT` property of WAI moq-streaming-format §5: the object's
/// receipt commitment, unsigned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WaiReceiptProperty {
/// jwp-receipts §2.1 BLAKE3 over the envelope bytes.
pub content_hash: [u8; 32],
/// Microjoules producing the object; 0 is the unmetered marker.
pub joules_micro: u64,
/// The acquisition class's label; empty beside 0.
pub acquisition: String,
/// For `OnChipCounter` and `CalibratedInstrument`, the evidence after the
/// class's tag byte; empty for every other class.
pub acquisition_evidence: Vec<u8>,
/// The link hash of the group receipt covering the object.
pub group_receipt: [u8; 32],
}
/// The class labels a property may name, with their tags in the signed
/// encoding of WAI energy-measurement §2.7.
const CLASS_LABELS: [(&str, u8); 5] = [("HwShunt", 1), ("ModelBased", 2), ("Estimator", 3), ("OnChipCounter", 4), ("CalibratedInstrument", 5)];
impl WaiReceiptProperty {
/// The property's value bytes: `content_hash` (32 bytes), `joules_micro` (a
/// -22 varint), `acquisition` (a varint length, then UTF-8),
/// `acquisition_evidence` (a varint length, then bytes), `group_receipt` (32
/// bytes). Nothing follows.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(80 + self.acquisition.len() + self.acquisition_evidence.len());
out.extend_from_slice(&self.content_hash);
put_varint(&mut out, self.joules_micro);
put_varint(&mut out, self.acquisition.len() as u64);
out.extend_from_slice(self.acquisition.as_bytes());
put_varint(&mut out, self.acquisition_evidence.len() as u64);
out.extend_from_slice(&self.acquisition_evidence);
out.extend_from_slice(&self.group_receipt);
out
}
/// Read the value bytes; refused where a field is cut short, the label is not
/// UTF-8, or bytes follow the last field.
pub fn from_bytes(b: &[u8]) -> MoqResult<Self> {
let bad = |e: WireError| MoqError::ReceiptVerification(format!("WAI_RECEIPT property: {e}"));
let mut r = Reader::new(b);
let content_hash = r.array32("content_hash").map_err(bad)?;
let joules_micro = r.varint("joules_micro").map_err(bad)?;
let acquisition = std::str::from_utf8(r.length_prefixed(None, "acquisition").map_err(bad)?)
.map_err(|_| MoqError::ReceiptVerification("WAI_RECEIPT property: acquisition is not UTF-8".into()))?
.to_owned();
let acquisition_evidence = r.length_prefixed(None, "acquisition_evidence").map_err(bad)?.to_vec();
let group_receipt = r.array32("group_receipt").map_err(bad)?;
r.finish("WAI_RECEIPT property").map_err(bad)?;
Ok(WaiReceiptProperty { content_hash, joules_micro, acquisition, acquisition_evidence, group_receipt })
}
/// moq-streaming-format §5's rules on the property alone
/// (`property_invalid`): a zero figure with no class and no evidence; a
/// non-zero figure with a class this crate can read and, for a refined
/// hardware class, its evidence at the resolution floor; legacy `HwShunt`
/// with no evidence.
pub fn check(&self) -> MoqResult<()> {
let bad = |m: &str| Err(MoqError::ReceiptVerification(format!("WAI_RECEIPT property: {m}")));
if self.joules_micro == 0 {
return if self.acquisition.is_empty() && self.acquisition_evidence.is_empty() { Ok(()) } else { bad("a class beside the unmetered marker") };
}
let Some(&(_, tag)) = CLASS_LABELS.iter().find(|(l, _)| *l == self.acquisition) else {
return bad("a figure without a class this crate reads");
};
if tag == 1 {
return if self.acquisition_evidence.is_empty() { Ok(()) } else { bad("evidence beside HwShunt") };
}
let mut class = vec![tag];
class.extend_from_slice(&self.acquisition_evidence);
check_class(&class, self.joules_micro).or_else(|m| bad(&m))
}
/// The property a publisher sets for `object` under `group`: the object's
/// figure and, from its labelled leaf, its class. `None` where the receipt
/// gives a non-zero figure and no class, which no valid property can state.
pub fn from_receipts(object: &ObjectReceipt, group: &GroupReceipt) -> Option<Self> {
let (acquisition, acquisition_evidence) = match (&object.energy_class, object.joules_micro) {
(_, 0) => (String::new(), Vec::new()),
(Some(c), _) => {
let (&tag, ev) = c.split_first()?;
let label = CLASS_LABELS.iter().find(|(_, t)| *t == tag)?.0;
(label.to_owned(), ev.to_vec())
}
(None, _) => return None,
};
let p = WaiReceiptProperty { content_hash: object.content_hash, joules_micro: object.joules_micro, acquisition, acquisition_evidence, group_receipt: group.link_hash() };
p.check().ok()?;
Some(p)
}
/// Whether the property names this object and its group, and, where the
/// object's leaf is labelled, states the leaf's figure and class
/// (moq-streaming-format §5: otherwise `property_disagrees`).
pub fn agrees_with(&self, object: &ObjectReceipt, group: &GroupReceipt) -> bool {
if self.content_hash != object.content_hash || self.group_receipt != group.link_hash() {
return false;
}
match &object.energy_class {
None => true,
Some(c) => {
let mine = match CLASS_LABELS.iter().find(|(l, _)| *l == self.acquisition) {
Some(&(_, tag)) => {
let mut v = vec![tag];
v.extend_from_slice(&self.acquisition_evidence);
v
}
None if self.acquisition.is_empty() => vec![0],
None => return false,
};
self.joules_micro == object.joules_micro && &mine == c
}
}
}
}
// ── Objects ─────────────────────────────────────────────────────────────────
fn status_to_wire(s: ObjectStatus) -> MoqResult<WireStatus> {
match s {
ObjectStatus::Normal => Ok(WireStatus::Normal),
ObjectStatus::EndOfGroup => Ok(WireStatus::EndOfGroup),
ObjectStatus::EndOfTrack => Ok(WireStatus::EndOfTrack),
ObjectStatus::DoesNotExist | ObjectStatus::GroupDoesNotExist => Err(MoqError::WireEncode(
"-22 has no Object Status for an object or group that does not exist; it states that by a Prior Object or Group ID Gap property or a fetch End of Range".into(),
)),
}
}
fn status_from_wire(s: WireStatus) -> ObjectStatus {
match s {
WireStatus::Normal => ObjectStatus::Normal,
WireStatus::EndOfGroup => ObjectStatus::EndOfGroup,
WireStatus::EndOfTrack => ObjectStatus::EndOfTrack,
}
}
/// Everything an object carries besides its payload, as this crate reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectExtras {
pub receipt: Option<ObjectReceipt>,
pub wai_receipt: Option<WaiReceiptProperty>,
/// OBJECT_DELIVERY_TIMEOUT set on the object (§5.2, §10.2).
pub delivery_timeout_ms: Option<u64>,
}
/// The object's properties: this crate's, inside Immutable Properties, and
/// OBJECT_DELIVERY_TIMEOUT where the object sets one (§10.2: read on a
/// subgroup's first object).
pub fn object_properties(h: &ObjectHeader, extras: &ObjectExtras) -> MoqResult<Properties> {
let mut inner = Properties::new();
if h.mrl_total_layers != 0 {
inner.push(KeyValuePair::varint(prop::MRL_LAYER, h.mrl_layer as u64));
inner.push(KeyValuePair::varint(prop::MRL_TOTAL_LAYERS, h.mrl_total_layers as u64));
}
if h.wai_envelope {
inner.push(KeyValuePair::varint(prop::WAI_ENVELOPE, 1));
}
if let Some(w) = &extras.wai_receipt {
inner.push(KeyValuePair::bytes(prop::WAI_RECEIPT, w.to_bytes()));
}
if let Some(r) = &extras.receipt {
let json = serde_json::to_vec(r).map_err(|e| MoqError::WireEncode(format!("object receipt: {e}")))?;
inner.push(KeyValuePair::bytes(prop::JWP_OBJECT_RECEIPT, json));
}
let mut p = Properties::new();
if let Some(t) = extras.delivery_timeout_ms {
p.push(KeyValuePair::varint(wire_prop::OBJECT_DELIVERY_TIMEOUT, t));
}
if !inner.is_empty() {
p.set_immutable(&inner)?;
}
Ok(p)
}
/// Read this crate's properties back: the MRL fields and the envelope flag into
/// `h`, and the receipt, the WAI_RECEIPT property and the delivery timeout.
pub fn read_object_properties(p: &Properties, h: &mut ObjectHeader) -> MoqResult<ObjectExtras> {
let small = |v: Option<u64>, what: &str| -> MoqResult<u8> {
let v = v.unwrap_or(0);
u8::try_from(v).map_err(|_| MoqError::WireDecode(format!("{what} {v} is above 255")))
};
let one = |ty: u64| -> MoqResult<Option<KeyValuePair>> { Ok(p.find(ty)?) };
h.mrl_layer = small(one(prop::MRL_LAYER)?.and_then(|k| k.as_varint()), "MRL layer")?;
h.mrl_total_layers = small(one(prop::MRL_TOTAL_LAYERS)?.and_then(|k| k.as_varint()), "MRL total layers")?;
h.wai_envelope = one(prop::WAI_ENVELOPE)?.and_then(|k| k.as_varint()) == Some(1);
let wai_receipt = match one(prop::WAI_RECEIPT)?.as_ref().and_then(KeyValuePair::as_bytes) {
Some(b) => Some(WaiReceiptProperty::from_bytes(b)?),
None => None,
};
let receipt = match one(prop::JWP_OBJECT_RECEIPT)?.as_ref().and_then(KeyValuePair::as_bytes) {
Some(b) => Some(serde_json::from_slice(b).map_err(|e| MoqError::WireDecode(format!("object receipt: {e}")))?),
None => None,
};
let delivery_timeout_ms = one(wire_prop::OBJECT_DELIVERY_TIMEOUT)?.and_then(|k| k.as_varint());
Ok(ObjectExtras { receipt, wai_receipt, delivery_timeout_ms })
}
/// The subgroup header an object of this crate rides in: its track alias and
/// group, Subgroup ID 0, its priority, properties on.
pub fn subgroup_header_for(h: &ObjectHeader) -> SubgroupHeader {
SubgroupHeader {
track_alias: h.track_alias.0,
group_id: h.group_id.0,
subgroup_id: SubgroupId::Zero,
publisher_priority: Some(h.publisher_priority),
properties: true,
end_of_group: false,
first_object: h.object_id.0 == 0,
}
}
/// The -22 object for a header, payload and extras.
pub fn to_subgroup_object(h: &ObjectHeader, payload: &[u8], extras: &ObjectExtras) -> MoqResult<SubgroupObject> {
let status = status_to_wire(h.status)?;
let properties = if status == WireStatus::Normal { object_properties(h, extras)? } else { Properties::new() };
Ok(SubgroupObject { object_id: h.object_id.0, properties, status, payload: payload.to_vec() })
}
/// The header, payload and extras of a -22 subgroup object.
pub fn from_subgroup_object(sh: &SubgroupHeader, o: &SubgroupObject) -> MoqResult<(ObjectHeader, Bytes, ObjectExtras)> {
let mut h = ObjectHeader {
track_alias: TrackAlias(sh.track_alias),
group_id: GroupId(sh.group_id),
object_id: ObjectId(o.object_id),
publisher_priority: sh.publisher_priority.unwrap_or(DEFAULT_PRIORITY),
status: status_from_wire(o.status),
payload_len: o.payload.len() as u64,
mrl_layer: 0,
mrl_total_layers: 0,
wai_envelope: false,
};
let extras = read_object_properties(&o.properties, &mut h)?;
Ok((h, Bytes::from(o.payload.clone()), extras))
}
/// One object as a whole subgroup stream of its own: header, then the object.
pub fn encode_object_stream(h: &ObjectHeader, payload: &[u8], extras: &ObjectExtras) -> MoqResult<Vec<u8>> {
let sh = subgroup_header_for(h);
let mut out = Vec::new();
sh.encode(&mut out);
SubgroupCodec::new(sh).encode_object(&to_subgroup_object(h, payload, extras)?, &mut out)?;
Ok(out)
}
/// Read a subgroup stream that holds exactly one object.
pub fn decode_object_stream(b: &[u8]) -> MoqResult<(ObjectHeader, Bytes, ObjectExtras)> {
let ev = decode_uni_stream(b, GroupOrder::Ascending)?;
match ev.as_slice() {
[UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] => from_subgroup_object(sh, o),
_ => Err(MoqError::WireDecode("not a subgroup stream of one object".into())),
}
}
// ── Receipts tracks ─────────────────────────────────────────────────────────
/// One group's receipts, as one object of a receipts track carries them
/// (moq-streaming-format §4.1): the group receipt and the object receipts of one
/// group of the receipts track's media track.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceiptBundle {
pub group: GroupReceipt,
pub objects: Vec<ObjectReceipt>,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum BundleItem {
Group(GroupReceipt),
Object(ObjectReceipt),
}
impl ReceiptBundle {
/// The object's payload: a JSON array, the group receipt first, then the
/// object receipts, in this crate's serialisation of each.
pub fn to_payload(&self) -> MoqResult<Vec<u8>> {
let mut items: Vec<BundleItem> = vec![BundleItem::Group(self.group.clone())];
items.extend(self.objects.iter().cloned().map(BundleItem::Object));
serde_json::to_vec(&items).map_err(|e| MoqError::WireEncode(format!("receipt bundle: {e}")))
}
pub fn from_payload(b: &[u8]) -> MoqResult<Self> {
let v: Vec<serde_json::Value> = serde_json::from_slice(b).map_err(|e| MoqError::WireDecode(format!("receipt bundle: {e}")))?;
let mut it = v.into_iter();
let group: GroupReceipt = serde_json::from_value(it.next().ok_or_else(|| MoqError::WireDecode("receipt bundle: empty".into()))?)
.map_err(|e| MoqError::WireDecode(format!("receipt bundle group: {e}")))?;
let objects = it
.map(|o| serde_json::from_value(o).map_err(|e| MoqError::WireDecode(format!("receipt bundle object: {e}"))))
.collect::<MoqResult<Vec<ObjectReceipt>>>()?;
Ok(ReceiptBundle { group, objects })
}
/// The bundle as group `group` of a receipts track whose alias is
/// `receipts_alias`: a subgroup stream of one object, Object ID 0, at its
/// media track's priority (§4.1).
pub fn encode_stream(&self, receipts_alias: u64, group: u64, priority: u8) -> MoqResult<Vec<u8>> {
let sh = SubgroupHeader {
track_alias: receipts_alias,
group_id: group,
subgroup_id: SubgroupId::Zero,
publisher_priority: Some(priority),
properties: false,
end_of_group: true,
first_object: true,
};
let mut out = Vec::new();
sh.encode(&mut out);
SubgroupCodec::new(sh).encode_object(&SubgroupObject::normal(0, self.to_payload()?), &mut out)?;
Ok(out)
}
/// Read a receipts-track stream: its header and the bundle of its one object.
pub fn decode_stream(b: &[u8]) -> MoqResult<(SubgroupHeader, Self)> {
let ev = decode_uni_stream(b, GroupOrder::Ascending)?;
match ev.as_slice() {
[UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] => Ok((*sh, Self::from_payload(&o.payload)?)),
_ => Err(MoqError::WireDecode("not a receipts-track stream of one object".into())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receipt::ReceiptBuilder;
use ed25519_dalek::SigningKey;
fn signed(class: Option<Vec<u8>>, joules: u64) -> (ObjectReceipt, GroupReceipt) {
let key = SigningKey::from_bytes(&[7; 32]);
let hash = ObjectReceipt::hash_payload(b"envelope");
let mut b = ReceiptBuilder::new(TrackAlias(3), GroupId(9));
match &class {
Some(c) => {
b.add_labelled_object(hash, joules, "o", "t", c.clone()).unwrap();
}
None => {
b.add_object(hash, joules, "o", "t");
}
}
let proof = b.proof_for(0).unwrap();
let group = match &class {
Some(_) => b.finalize_labelled(&key, "p", "t", None).unwrap(),
None => b.finalize(&key, "p", "t"),
};
let o = ObjectReceipt { track_alias: TrackAlias(3), group_id: GroupId(9), object_id: ObjectId(0), content_hash: hash, joules_micro: joules, origin: "o".into(), merkle_proof: proof, energy_class: class };
(o, group)
}
#[test]
fn the_property_from_receipts_and_back() {
// Unmetered.
let (o, g) = signed(Some(vec![0]), 0);
let p = WaiReceiptProperty::from_receipts(&o, &g).unwrap();
assert_eq!((p.joules_micro, p.acquisition.as_str()), (0, ""));
assert_eq!(WaiReceiptProperty::from_bytes(&p.to_bytes()).unwrap(), p);
assert!(p.agrees_with(&o, &g));
// A refined class: OnChipCounter, 1% over 1 s, filtering off.
let mut c = vec![4];
c.extend_from_slice(&10_000u32.to_be_bytes());
c.extend_from_slice(&1_000_000u64.to_be_bytes());
c.push(1);
let (o, g) = signed(Some(c), 1_200_000);
let p = WaiReceiptProperty::from_receipts(&o, &g).unwrap();
assert_eq!(p.acquisition, "OnChipCounter");
assert_eq!(p.acquisition_evidence.len(), 13);
assert!(p.agrees_with(&o, &g));
let mut other = p.clone();
other.joules_micro += 1;
assert!(!other.agrees_with(&o, &g));
// A legacy figure with no class has no valid property.
let (o, g) = signed(None, 100);
assert_eq!(WaiReceiptProperty::from_receipts(&o, &g), None);
// Trailing bytes and a class beside zero are refused.
let mut b = p.to_bytes();
b.push(0);
assert!(WaiReceiptProperty::from_bytes(&b).is_err());
let z = WaiReceiptProperty { joules_micro: 0, ..p };
assert!(z.check().is_err());
}
#[test]
fn an_object_rides_a_subgroup_stream() {
let (o, g) = signed(Some(vec![0]), 0);
let h = ObjectHeader {
track_alias: TrackAlias(3),
group_id: GroupId(9),
object_id: ObjectId(0),
publisher_priority: 40,
status: ObjectStatus::Normal,
payload_len: 8,
mrl_layer: 1,
mrl_total_layers: 3,
wai_envelope: true,
};
let extras = ObjectExtras { wai_receipt: WaiReceiptProperty::from_receipts(&o, &g), receipt: Some(o), delivery_timeout_ms: Some(50) };
let b = encode_object_stream(&h, b"envelope", &extras).unwrap();
let (h2, payload, x2) = decode_object_stream(&b).unwrap();
assert_eq!((h2.track_alias, h2.group_id, h2.object_id, h2.publisher_priority), (h.track_alias, h.group_id, h.object_id, 40));
assert_eq!((h2.mrl_layer, h2.mrl_total_layers, h2.wai_envelope, h2.payload_len), (1, 3, true, 8));
assert_eq!(&payload[..], b"envelope");
assert_eq!(x2, extras);
let bad = ObjectHeader { status: ObjectStatus::DoesNotExist, ..h };
assert!(encode_object_stream(&bad, b"", &ObjectExtras { receipt: None, wai_receipt: None, delivery_timeout_ms: None }).is_err());
}
#[test]
fn a_bundle_rides_its_receipts_track() {
let (o, g) = signed(Some(vec![0]), 0);
let bundle = ReceiptBundle { group: g, objects: vec![o] };
let b = bundle.encode_stream(11, 9, 40).unwrap();
let (sh, back) = ReceiptBundle::decode_stream(&b).unwrap();
assert_eq!((sh.track_alias, sh.group_id, sh.publisher_priority), (11, 9, Some(40)));
assert_eq!(back, bundle);
}
}

View file

@ -0,0 +1,78 @@
//! Which MOQT draft a session speaks.
//!
//! Since -15 a draft is negotiated before the first MOQT byte: by ALPN on
//! native QUIC and by `WT-Available-Protocols` on WebTransport, each draft
//! named `moqt-NN` (`draft-ietf-moq-transport-22` §6.2). No two drafts
//! interoperate, so a session is one draft from its first byte to its last.
//!
//! This crate implements -22. A draft added later (or an earlier one, for
//! interoperation with a deployed relay) is a codec module of its own beside
//! [`crate::draft22`] and a variant here; nothing that selects by [`Draft`]
//! changes shape.
/// A MOQT draft this crate can speak.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum Draft {
/// `draft-ietf-moq-transport-22`, 1 October 2026.
D22,
}
impl Draft {
/// Every draft this crate speaks, most preferred first.
pub const SUPPORTED: &'static [Draft] = &[Draft::D22];
/// The draft number.
pub const fn number(self) -> u32 {
match self {
Draft::D22 => 22,
}
}
/// The ALPN token (and WebTransport protocol) that selects it.
pub const fn alpn(self) -> &'static [u8] {
match self {
Draft::D22 => crate::draft22::ALPN,
}
}
/// The conventional 32-bit code of the draft, `0xff000000 + NN`.
pub const fn version_code(self) -> u32 {
0xff00_0000 + self.number()
}
pub fn from_alpn(alpn: &[u8]) -> Option<Draft> {
Self::SUPPORTED.iter().copied().find(|d| d.alpn() == alpn)
}
pub fn from_version_code(code: u32) -> Option<Draft> {
Self::SUPPORTED.iter().copied().find(|d| d.version_code() == code)
}
/// The first draft of `offered` (in the peer's order of preference) that this
/// crate speaks, for a server choosing among a client's ALPNs.
pub fn select<'a>(offered: impl IntoIterator<Item = &'a [u8]>) -> Option<Draft> {
offered.into_iter().find_map(Draft::from_alpn)
}
/// The ALPN list a client offers, most preferred first.
pub fn alpn_list() -> Vec<Vec<u8>> {
Self::SUPPORTED.iter().map(|d| d.alpn().to_vec()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draft_22_by_alpn_and_code() {
assert_eq!(Draft::D22.alpn(), b"moqt-22");
assert_eq!(Draft::D22.version_code(), 0xff00_0016);
assert_eq!(Draft::from_alpn(b"moqt-22"), Some(Draft::D22));
assert_eq!(Draft::from_alpn(b"moqt-18"), None);
assert_eq!(Draft::from_alpn(b"moq-00"), None);
let offered: [&[u8]; 3] = [b"moqt-18", b"h3", b"moqt-22"];
assert_eq!(Draft::select(offered), Some(Draft::D22));
assert_eq!(Draft::from_version_code(crate::draft22::VERSION), Some(Draft::D22));
}
}

View file

@ -0,0 +1,163 @@
//! Status and error codes of `draft-ietf-moq-transport-22` §12 and §16.11.
//!
//! Each registry is a newtype over the wire value, so a code this crate does
//! not name still round-trips. §13 requires an unknown code in any error
//! context to be treated as `INTERNAL_ERROR` for that context and never, on its
//! own, to close the session: [`SessionError::effective`] and its siblings
//! return that reading.
macro_rules! code_registry {
(
$(#[$meta:meta])*
$name:ident, internal = $internal:expr, { $( $(#[$cm:meta])* $konst:ident = $val:expr ),* $(,)? }
) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct $name(pub u64);
impl $name {
$( $(#[$cm])* pub const $konst: $name = $name($val); )*
/// Every code this draft registers, with its name.
pub const REGISTERED: &'static [($name, &'static str)] = &[ $( ($name($val), stringify!($konst)) ),* ];
/// The registered name, or `None` for a code this draft does not register.
pub fn name(self) -> Option<&'static str> {
Self::REGISTERED.iter().find(|(c, _)| *c == self).map(|(_, n)| *n)
}
/// §13: a code this draft does not register reads as `INTERNAL_ERROR`.
pub fn effective(self) -> $name {
if self.name().is_some() { self } else { $internal }
}
/// Whether the value is a §13 grease value (`0x7f * N + 0x9D`).
pub fn is_grease(self) -> bool {
is_grease(self.0)
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.name() {
Some(n) => f.write_str(n),
None => write!(f, "{:#x}", self.0),
}
}
}
};
}
/// Whether `v` follows §13's grease pattern `0x7f * N + 0x9D`.
pub fn is_grease(v: u64) -> bool {
v >= 0x9d && (v - 0x9d).is_multiple_of(0x7f)
}
code_registry! {
/// Session termination codes (§12.2, Table 19).
SessionError, internal = SessionError::INTERNAL_ERROR, {
NO_ERROR = 0x0,
INTERNAL_ERROR = 0x1,
UNAUTHORIZED = 0x2,
PROTOCOL_VIOLATION = 0x3,
INVALID_REQUEST_ID = 0x4,
DUPLICATE_TRACK_ALIAS = 0x5,
KEY_VALUE_FORMATTING_ERROR = 0x6,
INVALID_PATH = 0x8,
MALFORMED_PATH = 0x9,
GOAWAY_TIMEOUT = 0x10,
CONTROL_MESSAGE_TIMEOUT = 0x11,
DATA_STREAM_TIMEOUT = 0x12,
AUTH_TOKEN_CACHE_OVERFLOW = 0x13,
DUPLICATE_AUTH_TOKEN_ALIAS = 0x14,
MALFORMED_AUTH_TOKEN = 0x16,
UNKNOWN_AUTH_TOKEN_ALIAS = 0x17,
EXPIRED_AUTH_TOKEN = 0x18,
INVALID_AUTHORITY = 0x19,
MALFORMED_AUTHORITY = 0x1a,
TOO_MANY_REQUEST_UPDATES = 0x1b,
}
}
code_registry! {
/// REQUEST_ERROR codes (§12.3, Table 20).
RequestErrorCode, internal = RequestErrorCode::INTERNAL_ERROR, {
INTERNAL_ERROR = 0x0,
UNAUTHORIZED = 0x1,
TIMEOUT = 0x2,
NOT_SUPPORTED = 0x3,
MALFORMED_AUTH_TOKEN = 0x4,
EXPIRED_AUTH_TOKEN = 0x5,
GOING_AWAY = 0x6,
EXCESSIVE_LOAD = 0x9,
DOES_NOT_EXIST = 0x10,
INVALID_RANGE = 0x11,
MALFORMED_TRACK = 0x12,
UNINTERESTED = 0x20,
PREFIX_OVERLAP = 0x30,
NAMESPACE_TOO_LARGE = 0x31,
UNSUPPORTED_EXTENSION = 0x33,
REDIRECT = 0x34,
CONFLICTING_FILTERS = 0x35,
INVALID_FILTER = 0x36,
}
}
code_registry! {
/// PUBLISH_DONE status codes (§12.4, Table 21).
PublishDoneCode, internal = PublishDoneCode::INTERNAL_ERROR, {
INTERNAL_ERROR = 0x0,
UNAUTHORIZED = 0x1,
TRACK_ENDED = 0x2,
GOING_AWAY = 0x4,
TOO_FAR_BEHIND = 0x5,
EXPIRED = 0x6,
UPDATE_FAILED = 0x8,
EXCESSIVE_LOAD = 0x9,
MALFORMED_TRACK = 0x12,
}
}
code_registry! {
/// Stream reset error codes (§12.5, Table 22).
StreamResetCode, internal = StreamResetCode::INTERNAL_ERROR, {
INTERNAL_ERROR = 0x0,
CANCELLED = 0x1,
DELIVERY_TIMEOUT = 0x2,
SESSION_CLOSED = 0x3,
GOING_AWAY = 0x4,
TOO_FAR_BEHIND = 0x5,
UNKNOWN_OBJECT_STATUS = 0x6,
EXPIRED_AUTH_TOKEN = 0x7,
EXCESSIVE_LOAD = 0x9,
MALFORMED_TRACK = 0x12,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_codes_read_as_internal_error() {
assert_eq!(SessionError(0x7).effective(), SessionError::INTERNAL_ERROR);
assert_eq!(SessionError::PROTOCOL_VIOLATION.effective(), SessionError::PROTOCOL_VIOLATION);
assert_eq!(RequestErrorCode(0x9d).effective(), RequestErrorCode::INTERNAL_ERROR);
assert_eq!(PublishDoneCode(0x3).effective(), PublishDoneCode::INTERNAL_ERROR);
assert_eq!(StreamResetCode(0x8).effective(), StreamResetCode::INTERNAL_ERROR);
assert_eq!(RequestErrorCode::REDIRECT.to_string(), "REDIRECT");
assert_eq!(SessionError(0x7).to_string(), "0x7");
}
#[test]
fn grease_follows_the_pattern() {
assert!(is_grease(0x9d) && is_grease(0x11c) && is_grease(0x3fff_ffff_ffff_ffde));
assert!(!is_grease(0x9c) && !is_grease(0x9e) && !is_grease(0));
for (c, _) in SessionError::REGISTERED {
assert!(!c.is_grease(), "{c}");
}
for (c, _) in RequestErrorCode::REGISTERED {
assert!(!c.is_grease(), "{c}");
}
}
}

View file

@ -0,0 +1,586 @@
//! Control messages (§9, Table 5).
//!
//! Every message on a control or request stream is `Type (vi64)`, `Length
//! (16)` and a body of exactly Length bytes. SETUP opens each endpoint's
//! unidirectional control stream; GOAWAY may follow it there. Every other
//! message is on a bidirectional request stream; [`super::request`] holds the
//! rules of which message may come where.
use super::codes::{PublishDoneCode, RequestErrorCode};
use super::error::{WireError, WireResult};
use super::io::{put_length_prefixed, put_varint, Reader};
use super::kvp::{Properties, SetupOptions};
use super::names::{decode_namespace, decode_track_name, encode_namespace, encode_track_name, Location};
use super::params::{ctx, ParamContext, Parameters};
use super::request::{Perspective, RequestKind};
use crate::track::{TrackName, TrackNamespace};
/// Message types (Table 5).
pub mod msg {
pub const REQUEST_UPDATE: u64 = 0x02;
pub const SUBSCRIBE: u64 = 0x03;
pub const SUBSCRIBE_OK: u64 = 0x04;
pub const REQUEST_ERROR: u64 = 0x05;
pub const PUBLISH_NAMESPACE: u64 = 0x06;
pub const REQUEST_OK: u64 = 0x07;
pub const NAMESPACE: u64 = 0x08;
pub const PUBLISH_DONE: u64 = 0x0b;
pub const TRACK_STATUS: u64 = 0x0d;
pub const NAMESPACE_DONE: u64 = 0x0e;
pub const PUBLISH_SKIPPED: u64 = 0x0f;
pub const GOAWAY: u64 = 0x10;
pub const FETCH: u64 = 0x16;
pub const FETCH_OK: u64 = 0x18;
pub const PUBLISH: u64 = 0x1d;
pub const PUBLISH_STATE_NOTIFY: u64 = 0x22;
pub const SUBSCRIBE_NAMESPACE: u64 = 0x50;
pub const SUBSCRIBE_TRACKS: u64 = 0x51;
pub const SETUP: u64 = 0x2f00;
/// Types earlier drafts used, reserved in -22 (Table 5): an endpoint that
/// receives one has received an unknown message.
pub const RESERVED: [u64; 6] = [0x01, 0x40, 0x41, 0x20, 0x21, 0x1e];
pub fn name(t: u64) -> Option<&'static str> {
Some(match t {
REQUEST_UPDATE => "REQUEST_UPDATE",
SUBSCRIBE => "SUBSCRIBE",
SUBSCRIBE_OK => "SUBSCRIBE_OK",
REQUEST_ERROR => "REQUEST_ERROR",
PUBLISH_NAMESPACE => "PUBLISH_NAMESPACE",
REQUEST_OK => "REQUEST_OK",
NAMESPACE => "NAMESPACE",
PUBLISH_DONE => "PUBLISH_DONE",
TRACK_STATUS => "TRACK_STATUS",
NAMESPACE_DONE => "NAMESPACE_DONE",
PUBLISH_SKIPPED => "PUBLISH_SKIPPED",
GOAWAY => "GOAWAY",
FETCH => "FETCH",
FETCH_OK => "FETCH_OK",
PUBLISH => "PUBLISH",
PUBLISH_STATE_NOTIFY => "PUBLISH_STATE_NOTIFY",
SUBSCRIBE_NAMESPACE => "SUBSCRIBE_NAMESPACE",
SUBSCRIBE_TRACKS => "SUBSCRIBE_TRACKS",
SETUP => "SETUP",
_ => return None,
})
}
}
/// The largest message body (Length is 16 bits).
pub const MAX_MESSAGE_BODY: u64 = 0xffff;
/// The largest Reason Phrase (§8.5).
pub const MAX_REASON_PHRASE: u64 = 1024;
/// The largest New Session URI in GOAWAY (§9.2).
pub const MAX_GOAWAY_URI: u64 = 8192;
/// SETUP (§9.1): the first message on each endpoint's control stream.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Setup {
pub options: SetupOptions,
}
impl Setup {
/// The checks that depend on who sent it (§9.1.1, §9.1.2, §9.1.4): a server
/// sends no PATH or AUTHORITY, and a client's SETUP registers or uses
/// tokens only by value or REGISTER. The session closes with INVALID_PATH,
/// INVALID_AUTHORITY or PROTOCOL_VIOLATION; the code is in the returned
/// [`super::codes::SessionError`].
pub fn check_from(&self, sender: Perspective, over_webtransport: bool) -> Result<(), super::codes::SessionError> {
use super::codes::SessionError as E;
use super::token::Token;
if self.options.path().is_some() && (sender == Perspective::Server || over_webtransport) {
return Err(E::INVALID_PATH);
}
if self.options.authority().is_some() && (sender == Perspective::Server || over_webtransport) {
return Err(E::INVALID_AUTHORITY);
}
if sender == Perspective::Client
&& self.options.authorization_tokens().iter().any(|t| matches!(t, Token::Delete { .. } | Token::UseAlias { .. }))
{
return Err(E::PROTOCOL_VIOLATION);
}
Ok(())
}
}
/// GOAWAY (§9.2).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Goaway {
/// Empty: reuse the current URI. A client always sends it empty.
pub new_session_uri: Vec<u8>,
pub timeout_ms: u64,
}
impl Goaway {
/// A server that receives a GOAWAY with a URI closes the session with
/// PROTOCOL_VIOLATION (§9.2).
pub fn check_from(&self, sender: Perspective) -> WireResult<()> {
if sender == Perspective::Client && !self.new_session_uri.is_empty() {
return Err(WireError::UnexpectedMessage("a client's GOAWAY names a new session URI"));
}
Ok(())
}
}
/// SUBSCRIBE (§9.6), FETCH (§9.11) and TRACK_STATUS (§9.13): a request for one
/// track.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrackRequest {
pub request_id: u64,
pub namespace: TrackNamespace,
pub name: TrackName,
pub params: Parameters,
}
pub type Subscribe = TrackRequest;
pub type Fetch = TrackRequest;
pub type TrackStatus = TrackRequest;
/// SUBSCRIBE_OK (§9.7).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SubscribeOk {
pub track_alias: u64,
pub params: Parameters,
pub track_properties: Properties,
}
/// PUBLISH (§9.8).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Publish {
pub request_id: u64,
pub namespace: TrackNamespace,
pub name: TrackName,
pub track_alias: u64,
pub params: Parameters,
pub track_properties: Properties,
}
/// PUBLISH_DONE (§9.9).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishDone {
pub status: PublishDoneCode,
/// Data streams opened for the subscription; 2^64-1 when not known exactly.
pub stream_count: u64,
/// The Reason Phrase (§8.5): UTF-8 by the draft, kept as bytes.
pub reason: Vec<u8>,
}
/// FETCH_OK (§9.12).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchOk {
pub end_of_track: bool,
pub end_location: Location,
pub params: Parameters,
pub track_properties: Properties,
}
/// PUBLISH_NAMESPACE (§9.14), SUBSCRIBE_NAMESPACE (§9.15) and SUBSCRIBE_TRACKS
/// (§9.18): a request over a Track Namespace Prefix.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NamespaceRequest {
pub request_id: u64,
pub prefix: TrackNamespace,
pub params: Parameters,
}
/// PUBLISH_SKIPPED (§9.19).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishSkipped {
pub suffix: TrackNamespace,
pub name: TrackName,
}
/// REQUEST_UPDATE (§9.5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestUpdate {
pub request_id: u64,
pub params: Parameters,
}
/// REQUEST_OK (§9.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestOk {
pub params: Parameters,
pub track_properties: Properties,
}
/// The Redirect structure of a REQUEST_ERROR with code REDIRECT (§9.4.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Redirect {
pub connect_uri: Vec<u8>,
pub namespace: TrackNamespace,
pub name: TrackName,
}
/// REQUEST_ERROR (§9.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestError {
pub code: RequestErrorCode,
/// Milliseconds before a retry, plus one; 0 is "do not retry".
pub retry_interval: u64,
pub reason: Vec<u8>,
/// Present exactly when `code` is REDIRECT.
pub redirect: Option<Redirect>,
}
/// One control message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ControlMessage {
Setup(Setup),
Goaway(Goaway),
Subscribe(TrackRequest),
SubscribeOk(SubscribeOk),
PublishStateNotify(Parameters),
Publish(Publish),
PublishDone(PublishDone),
Fetch(TrackRequest),
FetchOk(FetchOk),
TrackStatus(TrackRequest),
PublishNamespace(NamespaceRequest),
SubscribeNamespace(NamespaceRequest),
SubscribeTracks(NamespaceRequest),
/// NAMESPACE (§9.16): a Track Namespace Suffix.
Namespace(TrackNamespace),
/// NAMESPACE_DONE (§9.17): a Track Namespace Suffix.
NamespaceDone(TrackNamespace),
PublishSkipped(PublishSkipped),
RequestUpdate(RequestUpdate),
RequestOk(RequestOk),
RequestError(RequestError),
}
fn reason_phrase(r: &mut Reader<'_>) -> WireResult<Vec<u8>> {
Ok(r.length_prefixed(Some(MAX_REASON_PHRASE), "reason phrase")?.to_vec())
}
fn track_properties(r: &mut Reader<'_>) -> WireResult<Properties> {
let p = Properties::decode(r.rest(), "track properties")?;
p.check_track()?;
Ok(p)
}
impl ControlMessage {
pub fn type_code(&self) -> u64 {
match self {
ControlMessage::Setup(_) => msg::SETUP,
ControlMessage::Goaway(_) => msg::GOAWAY,
ControlMessage::Subscribe(_) => msg::SUBSCRIBE,
ControlMessage::SubscribeOk(_) => msg::SUBSCRIBE_OK,
ControlMessage::PublishStateNotify(_) => msg::PUBLISH_STATE_NOTIFY,
ControlMessage::Publish(_) => msg::PUBLISH,
ControlMessage::PublishDone(_) => msg::PUBLISH_DONE,
ControlMessage::Fetch(_) => msg::FETCH,
ControlMessage::FetchOk(_) => msg::FETCH_OK,
ControlMessage::TrackStatus(_) => msg::TRACK_STATUS,
ControlMessage::PublishNamespace(_) => msg::PUBLISH_NAMESPACE,
ControlMessage::SubscribeNamespace(_) => msg::SUBSCRIBE_NAMESPACE,
ControlMessage::SubscribeTracks(_) => msg::SUBSCRIBE_TRACKS,
ControlMessage::Namespace(_) => msg::NAMESPACE,
ControlMessage::NamespaceDone(_) => msg::NAMESPACE_DONE,
ControlMessage::PublishSkipped(_) => msg::PUBLISH_SKIPPED,
ControlMessage::RequestUpdate(_) => msg::REQUEST_UPDATE,
ControlMessage::RequestOk(_) => msg::REQUEST_OK,
ControlMessage::RequestError(_) => msg::REQUEST_ERROR,
}
}
pub fn name(&self) -> &'static str {
msg::name(self.type_code()).expect("every variant is named")
}
/// The Request ID a request message consumes (§6.4.2.1); `None` for a
/// response.
pub fn request_id(&self) -> Option<u64> {
match self {
ControlMessage::Subscribe(r) | ControlMessage::Fetch(r) | ControlMessage::TrackStatus(r) => Some(r.request_id),
ControlMessage::Publish(p) => Some(p.request_id),
ControlMessage::PublishNamespace(r) | ControlMessage::SubscribeNamespace(r) | ControlMessage::SubscribeTracks(r) => Some(r.request_id),
ControlMessage::RequestUpdate(u) => Some(u.request_id),
_ => None,
}
}
/// The request a message opens a request stream with (§6.3), if it is one.
pub fn opens_request(&self) -> Option<RequestKind> {
Some(match self {
ControlMessage::Subscribe(_) => RequestKind::Subscribe,
ControlMessage::Publish(_) => RequestKind::Publish,
ControlMessage::Fetch(_) => RequestKind::Fetch,
ControlMessage::TrackStatus(_) => RequestKind::TrackStatus,
ControlMessage::PublishNamespace(_) => RequestKind::PublishNamespace,
ControlMessage::SubscribeNamespace(_) => RequestKind::SubscribeNamespace,
ControlMessage::SubscribeTracks(_) => RequestKind::SubscribeTracks,
_ => return None,
})
}
/// The message, framed: Type, Length and body.
pub fn encode(&self) -> WireResult<Vec<u8>> {
let mut body = Vec::new();
self.encode_body(&mut body)?;
if body.len() as u64 > MAX_MESSAGE_BODY {
return Err(WireError::LengthExceedsLimit { what: "control message", len: body.len() as u64, limit: MAX_MESSAGE_BODY });
}
let mut out = Vec::with_capacity(body.len() + 6);
put_varint(&mut out, self.type_code());
out.extend_from_slice(&(body.len() as u16).to_be_bytes());
out.extend_from_slice(&body);
Ok(out)
}
fn encode_body(&self, out: &mut Vec<u8>) -> WireResult<()> {
let req = |r: &TrackRequest, c: &ParamContext, out: &mut Vec<u8>| -> WireResult<()> {
put_varint(out, r.request_id);
encode_namespace(&r.namespace, out)?;
encode_track_name(&r.namespace, &r.name, out)?;
r.params.encode_counted(out, Some(c))
};
let nsreq = |r: &NamespaceRequest, c: &ParamContext, out: &mut Vec<u8>| -> WireResult<()> {
put_varint(out, r.request_id);
encode_namespace(&r.prefix, out)?;
r.params.encode_counted(out, Some(c))
};
match self {
ControlMessage::Setup(s) => s.options.encode(out)?,
ControlMessage::Goaway(g) => {
put_length_prefixed(out, &g.new_session_uri, Some(MAX_GOAWAY_URI), "GOAWAY new session URI")?;
put_varint(out, g.timeout_ms);
}
ControlMessage::Subscribe(r) => req(r, &ctx::SUBSCRIBE, out)?,
ControlMessage::Fetch(r) => req(r, &ctx::FETCH, out)?,
ControlMessage::TrackStatus(r) => req(r, &ctx::TRACK_STATUS, out)?,
ControlMessage::SubscribeOk(s) => {
put_varint(out, s.track_alias);
s.params.encode_counted(out, Some(&ctx::SUBSCRIBE_OK))?;
s.track_properties.encode(out)?;
}
ControlMessage::PublishStateNotify(p) => p.encode_counted(out, Some(&ctx::PUBLISH_STATE_NOTIFY))?,
ControlMessage::Publish(p) => {
put_varint(out, p.request_id);
encode_namespace(&p.namespace, out)?;
encode_track_name(&p.namespace, &p.name, out)?;
put_varint(out, p.track_alias);
p.params.encode_counted(out, Some(&ctx::PUBLISH))?;
p.track_properties.encode(out)?;
}
ControlMessage::PublishDone(d) => {
put_varint(out, d.status.0);
put_varint(out, d.stream_count);
put_length_prefixed(out, &d.reason, Some(MAX_REASON_PHRASE), "reason phrase")?;
}
ControlMessage::FetchOk(f) => {
out.push(f.end_of_track as u8);
f.end_location.encode(out);
f.params.encode_counted(out, Some(&ctx::FETCH_OK))?;
f.track_properties.encode(out)?;
}
ControlMessage::PublishNamespace(r) => nsreq(r, &ctx::PUBLISH_NAMESPACE, out)?,
ControlMessage::SubscribeNamespace(r) => nsreq(r, &ctx::SUBSCRIBE_NAMESPACE, out)?,
ControlMessage::SubscribeTracks(r) => nsreq(r, &ctx::SUBSCRIBE_TRACKS, out)?,
ControlMessage::Namespace(ns) | ControlMessage::NamespaceDone(ns) => encode_namespace(ns, out)?,
ControlMessage::PublishSkipped(s) => {
encode_namespace(&s.suffix, out)?;
encode_track_name(&s.suffix, &s.name, out)?;
}
ControlMessage::RequestUpdate(u) => {
put_varint(out, u.request_id);
u.params.encode_counted(out, Some(&ctx::REQUEST_UPDATE))?;
}
ControlMessage::RequestOk(o) => {
o.params.encode_counted(out, Some(&ctx::REQUEST_OK))?;
o.track_properties.encode(out)?;
}
ControlMessage::RequestError(e) => {
if e.redirect.is_some() != (e.code == RequestErrorCode::REDIRECT) {
return Err(WireError::Unencodable { what: "REQUEST_ERROR", reason: "a Redirect exactly when the code is REDIRECT" });
}
put_varint(out, e.code.0);
put_varint(out, e.retry_interval);
put_length_prefixed(out, &e.reason, Some(MAX_REASON_PHRASE), "reason phrase")?;
if let Some(rd) = &e.redirect {
put_varint(out, rd.connect_uri.len() as u64);
out.extend_from_slice(&rd.connect_uri);
encode_namespace(&rd.namespace, out)?;
encode_track_name(&rd.namespace, &rd.name, out)?;
}
}
}
Ok(())
}
/// Read one framed message from the front of `r`: its type (refused at once
/// when this draft does not define it), its 16-bit Length, then exactly
/// that many bytes of body.
pub fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
let t = r.varint("message type")?;
if msg::name(t).is_none() {
return Err(WireError::UnknownMessageType(t));
}
let len = r.u16("message length")?;
let mut b = r.sub(len as u64, "message body")?;
let m = Self::decode_body(t, &mut b)?;
b.finish(msg::name(t).unwrap_or("message"))?;
Ok(m)
}
/// Decode a buffer that holds exactly one framed message.
pub fn decode_exact(buf: &[u8]) -> WireResult<Self> {
let mut r = Reader::new(buf);
let m = Self::decode(&mut r)?;
r.finish("control message framing")?;
Ok(m)
}
fn decode_body(t: u64, r: &mut Reader<'_>) -> WireResult<Self> {
let req = |r: &mut Reader<'_>, c: &ParamContext| -> WireResult<TrackRequest> {
let request_id = r.varint("request id")?;
let namespace = decode_namespace(r)?;
let name = decode_track_name(r, &namespace)?;
let params = Parameters::decode_counted(r, c)?;
Ok(TrackRequest { request_id, namespace, name, params })
};
let nsreq = |r: &mut Reader<'_>, c: &ParamContext| -> WireResult<NamespaceRequest> {
let request_id = r.varint("request id")?;
let prefix = decode_namespace(r)?;
let params = Parameters::decode_counted(r, c)?;
Ok(NamespaceRequest { request_id, prefix, params })
};
Ok(match t {
msg::SETUP => ControlMessage::Setup(Setup { options: SetupOptions::decode(r)? }),
msg::GOAWAY => ControlMessage::Goaway(Goaway {
new_session_uri: r.length_prefixed(Some(MAX_GOAWAY_URI), "GOAWAY new session URI")?.to_vec(),
timeout_ms: r.varint("GOAWAY timeout")?,
}),
msg::SUBSCRIBE => ControlMessage::Subscribe(req(r, &ctx::SUBSCRIBE)?),
msg::FETCH => ControlMessage::Fetch(req(r, &ctx::FETCH)?),
msg::TRACK_STATUS => ControlMessage::TrackStatus(req(r, &ctx::TRACK_STATUS)?),
msg::SUBSCRIBE_OK => {
let track_alias = r.varint("track alias")?;
let params = Parameters::decode_counted(r, &ctx::SUBSCRIBE_OK)?;
ControlMessage::SubscribeOk(SubscribeOk { track_alias, params, track_properties: track_properties(r)? })
}
msg::PUBLISH_STATE_NOTIFY => ControlMessage::PublishStateNotify(Parameters::decode_counted(r, &ctx::PUBLISH_STATE_NOTIFY)?),
msg::PUBLISH => {
let request_id = r.varint("request id")?;
let namespace = decode_namespace(r)?;
let name = decode_track_name(r, &namespace)?;
let track_alias = r.varint("track alias")?;
let params = Parameters::decode_counted(r, &ctx::PUBLISH)?;
ControlMessage::Publish(Publish { request_id, namespace, name, track_alias, params, track_properties: track_properties(r)? })
}
msg::PUBLISH_DONE => ControlMessage::PublishDone(PublishDone {
status: PublishDoneCode(r.varint("status code")?),
stream_count: r.varint("stream count")?,
reason: reason_phrase(r)?,
}),
msg::FETCH_OK => {
let end_of_track = match r.u8("end of track")? {
0 => false,
1 => true,
v => return Err(WireError::InvalidValue { what: "FETCH_OK End Of Track", value: v as u64 }),
};
let end_location = Location::decode(r, "end location")?;
let params = Parameters::decode_counted(r, &ctx::FETCH_OK)?;
ControlMessage::FetchOk(FetchOk { end_of_track, end_location, params, track_properties: track_properties(r)? })
}
msg::PUBLISH_NAMESPACE => ControlMessage::PublishNamespace(nsreq(r, &ctx::PUBLISH_NAMESPACE)?),
msg::SUBSCRIBE_NAMESPACE => ControlMessage::SubscribeNamespace(nsreq(r, &ctx::SUBSCRIBE_NAMESPACE)?),
msg::SUBSCRIBE_TRACKS => ControlMessage::SubscribeTracks(nsreq(r, &ctx::SUBSCRIBE_TRACKS)?),
msg::NAMESPACE => ControlMessage::Namespace(decode_namespace(r)?),
msg::NAMESPACE_DONE => ControlMessage::NamespaceDone(decode_namespace(r)?),
msg::PUBLISH_SKIPPED => {
let suffix = decode_namespace(r)?;
let name = decode_track_name(r, &suffix)?;
ControlMessage::PublishSkipped(PublishSkipped { suffix, name })
}
msg::REQUEST_UPDATE => {
let request_id = r.varint("request id")?;
ControlMessage::RequestUpdate(RequestUpdate { request_id, params: Parameters::decode_counted(r, &ctx::REQUEST_UPDATE)? })
}
msg::REQUEST_OK => {
let params = Parameters::decode_counted(r, &ctx::REQUEST_OK)?;
ControlMessage::RequestOk(RequestOk { params, track_properties: track_properties(r)? })
}
msg::REQUEST_ERROR => {
let code = RequestErrorCode(r.varint("error code")?);
let retry_interval = r.varint("retry interval")?;
let reason = reason_phrase(r)?;
let redirect = if code == RequestErrorCode::REDIRECT {
let connect_uri = r.length_prefixed(None, "redirect connect URI")?.to_vec();
let namespace = decode_namespace(r)?;
let name = decode_track_name(r, &namespace)?;
Some(Redirect { connect_uri, namespace, name })
} else {
None
};
ControlMessage::RequestError(RequestError { code, retry_interval, reason, redirect })
}
other => return Err(WireError::UnknownMessageType(other)),
})
}
}
impl RequestOk {
/// The checks §9.3 makes once the request this answers is known: its
/// parameters, and Track Properties only in TRACK_STATUS_OK (and
/// SUBSCRIBE_TRACKS_OK, which §9.3 does not restrict).
pub fn check_for(&self, answers: RequestKind) -> WireResult<()> {
let c = match answers {
RequestKind::Publish => &ctx::PUBLISH_OK,
RequestKind::TrackStatus => &ctx::TRACK_STATUS_OK,
RequestKind::SubscribeNamespace | RequestKind::SubscribeTracks | RequestKind::PublishNamespace => &ctx::NAMESPACE_OK,
RequestKind::Subscribe | RequestKind::Fetch => {
return Err(WireError::UnexpectedMessage("REQUEST_OK answers neither SUBSCRIBE nor FETCH"));
}
};
self.params.check(c)?;
let may_carry = matches!(answers, RequestKind::TrackStatus | RequestKind::SubscribeTracks);
if !may_carry && !self.track_properties.is_empty() {
return Err(WireError::UnexpectedMessage("Track Properties in a REQUEST_OK that carries none"));
}
Ok(())
}
/// The checks for a REQUEST_OK that answers a REQUEST_UPDATE (§9.3).
pub fn check_for_update(&self) -> WireResult<()> {
self.params.check(&ctx::REQUEST_UPDATE_OK)?;
if !self.track_properties.is_empty() {
return Err(WireError::UnexpectedMessage("Track Properties in a REQUEST_UPDATE_OK"));
}
Ok(())
}
}
impl RequestUpdate {
/// The parameters §9.5 allows once the request being updated is known.
pub fn check_for(&self, updates: RequestKind) -> WireResult<()> {
let c = match updates {
RequestKind::Subscribe | RequestKind::Publish => &ctx::UPDATE_SUBSCRIPTION,
RequestKind::Fetch => &ctx::UPDATE_FETCH,
RequestKind::PublishNamespace => &ctx::UPDATE_PUBLISH_NAMESPACE,
RequestKind::SubscribeNamespace => &ctx::UPDATE_SUBSCRIBE_NAMESPACE,
RequestKind::SubscribeTracks => &ctx::UPDATE_SUBSCRIBE_TRACKS,
RequestKind::TrackStatus => return Err(WireError::UnexpectedMessage("REQUEST_UPDATE for a TRACK_STATUS")),
};
self.params.check(c)
}
}
impl RequestError {
/// A server that receives a Redirect with a Connect URI closes the session
/// with PROTOCOL_VIOLATION; a Redirect for a namespace-scoped request names
/// no track (§9.4.1).
pub fn check(&self, sender: Perspective, answers: RequestKind) -> WireResult<()> {
if let Some(rd) = &self.redirect {
if sender == Perspective::Client && !rd.connect_uri.is_empty() {
return Err(WireError::UnexpectedMessage("a client's Redirect names a Connect URI"));
}
let ns_scoped = matches!(answers, RequestKind::SubscribeNamespace | RequestKind::PublishNamespace | RequestKind::SubscribeTracks);
if ns_scoped && !rd.name.0.is_empty() {
return Err(WireError::UnexpectedMessage("a Redirect for a namespace request names a track"));
}
}
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,188 @@
//! Every refusal of the draft-22 codec, typed.
//!
//! A decoder never panics on input: it returns one of these. Each carries the
//! consequence the draft gives it ([`WireError::consequence`]) and a stable
//! name ([`WireError::kind`]) that the vector corpus under
//! `tests/vectors/draft-22/` and its independent checker use.
use super::codes::{RequestErrorCode, SessionError, StreamResetCode};
/// What the draft says the receiver does about a refusal.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Consequence {
/// Close the session with this code (§6.6, §12.2).
CloseSession(SessionError),
/// Reject the request with REQUEST_ERROR and this code (§12.3).
RejectRequest(RequestErrorCode),
/// The track is malformed (§12.1): cancel the subscription or fetch; a relay
/// ends its downstream subscriptions with MALFORMED_TRACK.
MalformedTrack,
/// A limit of this implementation, not of the draft: reset the stream with
/// this code (§15.5).
ResetStream(StreamResetCode),
}
impl std::fmt::Display for Consequence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Consequence::CloseSession(c) => write!(f, "close the session: {c}"),
Consequence::RejectRequest(c) => write!(f, "reject the request: {c}"),
Consequence::MalformedTrack => f.write_str("malformed track"),
Consequence::ResetStream(c) => write!(f, "reset the stream: {c}"),
}
}
}
/// A refusal of the draft-22 codec.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum WireError {
/// The input ends inside a field. Within a control message this is a body
/// shorter than its Length says (§9); on a data stream, a FIN inside an
/// object (§11.3).
#[error("truncated: {what}")]
Truncated { what: &'static str },
/// Bytes remain after the last field of a length-bounded structure: a
/// control message whose Length does not match its body (§9).
#[error("{what}: {count} byte(s) after the last field")]
TrailingBytes { what: &'static str, count: usize },
/// A control message type this draft does not define (§9, Table 5),
/// including the reserved types of earlier drafts.
#[error("unknown control message type {0:#x}")]
UnknownMessageType(u64),
/// A unidirectional stream type this draft does not define (§6.4.1).
#[error("unknown stream type {0:#x}")]
UnknownStreamType(u64),
/// A Type Flags or Serialization Flags value the draft forbids (§11.2.1,
/// §11.3.1, §11.4.1).
#[error("{what}: invalid flags {value:#x}")]
InvalidFlags { what: &'static str, value: u64 },
/// A Message Parameter this draft does not define (§9.20).
#[error("unknown message parameter {0:#x}")]
UnknownParameter(u64),
/// A Message Parameter, or a Setup Option, repeated where its definition
/// allows one instance (§9.1, §9.20).
#[error("{what} {ty:#x} repeated")]
Duplicate { what: &'static str, ty: u64 },
/// A Message Parameter in a message whose definition does not list it
/// (§9.20.1).
#[error("parameter {param:#x} is not allowed in {context}")]
ParameterNotAllowed { param: u64, context: &'static str },
/// The same Range Filter (parameter type, SetID and property type) twice
/// in one message (§3.3.2).
#[error("range filter {param:#x} with SetID {set_id} repeated")]
DuplicateFilter { param: u64, set_id: u8, property: Option<u64> },
/// A Range Filter the draft refuses: a delta past 2^64-1, a priority above
/// 255, an odd property type (§8.6, §9.20.10-14).
#[error("invalid filter: {reason}")]
InvalidFilter { reason: &'static str },
/// A delta-encoded type past 2^64-1 (§8.3, §9.20).
#[error("{what}: type delta past 2^64-1")]
TypeOverflow { what: &'static str },
/// A length past a limit the draft sets.
#[error("{what}: length {len} exceeds the draft's limit of {limit}")]
LengthExceedsLimit { what: &'static str, len: u64, limit: u64 },
/// A Track Namespace of more than 32 fields (§8.7).
#[error("track namespace of {0} fields; the limit is 32")]
TooManyNamespaceFields(u64),
/// A Track Namespace field of length 0 (§8.7).
#[error("empty track namespace field")]
EmptyNamespaceField,
/// A field outside the values the draft allows.
#[error("{what}: value {value:#x} is not allowed")]
InvalidValue { what: &'static str, value: u64 },
/// A Group ID, Object ID or Location that a delta carries past 2^64-1 or
/// below 0 (§9.20.9, §11.3.1, §11.4.1).
#[error("{what} past the range of a 64-bit integer")]
Overflow { what: &'static str },
/// Properties on an object whose status is not Normal (§11.1.2), or a
/// non-empty payload beside one.
#[error("properties on an object whose status is not Normal")]
PropertiesOnNonNormal,
/// An Object Status this draft does not register (§11.1.1).
#[error("unknown object status {0:#x}")]
UnknownObjectStatus(u64),
/// A fetch object that refers to a prior object's field when there is
/// none to refer to (§11.4.1, §11.4.1.2).
#[error("fetch object refers to the prior object's {field}, and there is none")]
NoPriorObject { field: &'static str },
/// A datagram with the PROPERTIES bit and a Properties Length of 0
/// (§11.2.1).
#[error("datagram PROPERTIES bit with zero-length properties")]
EmptyDatagramProperties,
/// An Authorization Token structure that cannot be decoded (§8.9).
#[error("authorization token cannot be decoded: {reason}")]
MalformedToken { reason: &'static str },
/// A message the draft forbids on the stream or at the position it came
/// (§6.3, §6.4.2, §9).
#[error("{0}")]
UnexpectedMessage(&'static str),
/// A Track is malformed (§12.1, §3.7, §10.7-10.9).
#[error("malformed track: {reason}")]
MalformedTrack { reason: &'static str },
/// A session rule with a code of its own was broken: a Request ID of the
/// wrong parity or seen before (INVALID_REQUEST_ID, §6.4.2.1), an update
/// past MAX_REQUEST_UPDATES (TOO_MANY_REQUEST_UPDATES, §9.1.7), a Track Alias
/// already in use (DUPLICATE_TRACK_ALIAS, §3.1.3).
#[error("{reason} ({code})")]
Session { code: SessionError, reason: &'static str },
/// A value the encoder cannot put on the wire.
#[error("cannot encode {what}: {reason}")]
Unencodable { what: &'static str, reason: &'static str },
/// A limit of this implementation (not the draft's) was passed.
#[error("{what}: length {len} exceeds this implementation's limit of {limit}")]
LocalLimit { what: &'static str, len: u64, limit: u64 },
}
impl WireError {
/// The stable name of this refusal, as the vector corpus writes it.
pub fn kind(&self) -> &'static str {
match self {
WireError::Truncated { .. } => "truncated",
WireError::TrailingBytes { .. } => "trailing-bytes",
WireError::UnknownMessageType(_) => "unknown-message-type",
WireError::UnknownStreamType(_) => "unknown-stream-type",
WireError::InvalidFlags { .. } => "invalid-flags",
WireError::UnknownParameter(_) => "unknown-parameter",
WireError::Duplicate { .. } => "duplicate",
WireError::ParameterNotAllowed { .. } => "parameter-not-allowed",
WireError::DuplicateFilter { .. } => "duplicate-filter",
WireError::InvalidFilter { .. } => "invalid-filter",
WireError::TypeOverflow { .. } => "type-overflow",
WireError::LengthExceedsLimit { .. } => "length-exceeds-limit",
WireError::TooManyNamespaceFields(_) => "too-many-namespace-fields",
WireError::EmptyNamespaceField => "empty-namespace-field",
WireError::InvalidValue { .. } => "invalid-value",
WireError::Overflow { .. } => "overflow",
WireError::PropertiesOnNonNormal => "properties-on-non-normal",
WireError::UnknownObjectStatus(_) => "unknown-object-status",
WireError::NoPriorObject { .. } => "no-prior-object",
WireError::EmptyDatagramProperties => "empty-datagram-properties",
WireError::MalformedToken { .. } => "malformed-token",
WireError::UnexpectedMessage(_) => "unexpected-message",
WireError::MalformedTrack { .. } => "malformed-track",
WireError::Session { .. } => "session-rule",
WireError::Unencodable { .. } => "unencodable",
WireError::LocalLimit { .. } => "local-limit",
}
}
/// What the draft says the receiver does about it.
pub fn consequence(&self) -> Consequence {
match self {
WireError::DuplicateFilter { .. } | WireError::InvalidFilter { .. } => {
Consequence::RejectRequest(RequestErrorCode::INVALID_FILTER)
}
// §8.9 (in -22): a Token that cannot be decoded closes the session
// with KEY_VALUE_FORMATTING_ERROR.
WireError::MalformedToken { .. } => Consequence::CloseSession(SessionError::KEY_VALUE_FORMATTING_ERROR),
WireError::MalformedTrack { .. } => Consequence::MalformedTrack,
WireError::LocalLimit { .. } => Consequence::ResetStream(StreamResetCode::EXCESSIVE_LOAD),
WireError::Session { code, .. } => Consequence::CloseSession(*code),
// -22 gives no code for an unknown message, stream or datagram type
// ("MUST close the session"), and PROTOCOL_VIOLATION for the rest.
_ => Consequence::CloseSession(SessionError::PROTOCOL_VIOLATION),
}
}
}
pub type WireResult<T> = Result<T, WireError>;

View file

@ -0,0 +1,139 @@
//! A bounded cursor over input bytes, and the write helpers the encoders share.
//!
//! Every read checks the bytes it needs are there and returns
//! [`WireError::Truncated`] otherwise; nothing allocates from a length the input
//! states before the bytes behind it are known to exist.
use super::error::{WireError, WireResult};
use super::varint;
/// A cursor over a byte slice.
#[derive(Clone, Debug)]
pub struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
/// Bytes not yet read.
pub fn remaining(&self) -> usize {
self.buf.len() - self.pos
}
pub fn is_empty(&self) -> bool {
self.remaining() == 0
}
/// Bytes read so far.
pub fn position(&self) -> usize {
self.pos
}
/// The unread bytes, without consuming them.
pub fn peek_rest(&self) -> &'a [u8] {
&self.buf[self.pos..]
}
pub fn varint(&mut self, what: &'static str) -> WireResult<u64> {
let (v, n) = varint::get(&self.buf[self.pos..], what)?;
self.pos += n;
Ok(v)
}
pub fn u8(&mut self, what: &'static str) -> WireResult<u8> {
let b = *self.buf.get(self.pos).ok_or(WireError::Truncated { what })?;
self.pos += 1;
Ok(b)
}
pub fn u16(&mut self, what: &'static str) -> WireResult<u16> {
let b = self.bytes(2, what)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
/// `n` bytes, where `n` may come from the input: refused unless they are there.
pub fn bytes(&mut self, n: u64, what: &'static str) -> WireResult<&'a [u8]> {
if n > self.remaining() as u64 {
return Err(WireError::Truncated { what });
}
let n = n as usize;
let s = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(s)
}
pub fn array32(&mut self, what: &'static str) -> WireResult<[u8; 32]> {
let b = self.bytes(32, what)?;
let mut a = [0u8; 32];
a.copy_from_slice(b);
Ok(a)
}
/// Every unread byte.
pub fn rest(&mut self) -> &'a [u8] {
let s = &self.buf[self.pos..];
self.pos = self.buf.len();
s
}
/// A reader over the next `n` bytes, which this reader then skips.
pub fn sub(&mut self, n: u64, what: &'static str) -> WireResult<Reader<'a>> {
Ok(Reader::new(self.bytes(n, what)?))
}
/// A varint length, at most `limit`, then that many bytes.
pub fn length_prefixed(&mut self, limit: Option<u64>, what: &'static str) -> WireResult<&'a [u8]> {
let n = self.varint(what)?;
if let Some(limit) = limit.filter(|l| n > *l) {
return Err(WireError::LengthExceedsLimit { what, len: n, limit });
}
self.bytes(n, what)
}
/// Refuse anything left over.
pub fn finish(&self, what: &'static str) -> WireResult<()> {
if self.is_empty() {
Ok(())
} else {
Err(WireError::TrailingBytes { what, count: self.remaining() })
}
}
}
/// Append a varint (shortest form).
pub fn put_varint(out: &mut Vec<u8>, v: u64) {
varint::put(out, v)
}
/// Append a varint length, then the bytes; refused past `limit`.
pub fn put_length_prefixed(out: &mut Vec<u8>, b: &[u8], limit: Option<u64>, what: &'static str) -> WireResult<()> {
if let Some(limit) = limit.filter(|l| b.len() as u64 > *l) {
return Err(WireError::LengthExceedsLimit { what, len: b.len() as u64, limit });
}
put_varint(out, b.len() as u64);
out.extend_from_slice(b);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_are_bounded() {
let mut r = Reader::new(&[0x02, 0xaa]);
assert_eq!(r.length_prefixed(None, "x"), Err(WireError::Truncated { what: "x" }));
let mut r = Reader::new(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaa]);
// A length of 2^64-1 with one byte behind it allocates nothing and refuses.
assert_eq!(r.length_prefixed(None, "x"), Err(WireError::Truncated { what: "x" }));
let mut r = Reader::new(&[0x03, 1, 2, 3]);
assert_eq!(r.length_prefixed(Some(2), "x"), Err(WireError::LengthExceedsLimit { what: "x", len: 3, limit: 2 }));
let mut r = Reader::new(&[0x01, 0x07, 0x09]);
assert_eq!(r.length_prefixed(None, "x").unwrap(), &[7]);
assert_eq!(r.finish("x"), Err(WireError::TrailingBytes { what: "x", count: 1 }));
}
}

View file

@ -0,0 +1,330 @@
//! A JSON view of decoded draft-22 values.
//!
//! The vector corpus under `tests/vectors/draft-22/` states what each vector
//! means in this form, and its independent checker builds the same form from
//! the bytes; the README of this crate describes it. Byte strings are lowercase
//! hex; a Track Namespace is a list of its fields in hex; a Location is
//! `[group, object]`; integers are JSON numbers (every one fits 64 bits).
use serde_json::{json, Map, Value};
use super::control::{msg, ControlMessage, NamespaceRequest, TrackRequest};
use super::data::{Datagram, DatagramContent, FetchEntry, RangeEnd, StreamHeader, SubgroupId, UniEvent};
use super::error::{Consequence, WireError};
use super::kvp::{KeyValuePair, KvValue, Properties};
use super::names::Location;
use super::params::{param, LocationFilter, Parameter, Parameters};
use super::token::Token;
use crate::track::TrackNamespace;
pub fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn ns(n: &TrackNamespace) -> Value {
Value::Array(n.0.iter().map(|f| Value::String(hex(f))).collect())
}
fn loc(l: &Location) -> Value {
json!([l.group, l.object])
}
pub fn pairs(ps: &[KeyValuePair]) -> Value {
Value::Array(
ps.iter()
.map(|p| match &p.value {
KvValue::Varint(v) => json!({"type": p.ty, "value": v}),
KvValue::Bytes(b) => json!({"type": p.ty, "bytes": hex(b)}),
})
.collect(),
)
}
pub fn properties(p: &Properties) -> Value {
pairs(&p.0)
}
fn token(t: &Token) -> Value {
match t {
Token::Delete { alias } => json!({"alias_type": 0, "alias": alias}),
Token::Register { alias, token_type, value } => json!({"alias_type": 1, "alias": alias, "token_type": token_type, "value": hex(value)}),
Token::UseAlias { alias } => json!({"alias_type": 2, "alias": alias}),
Token::UseValue { token_type, value } => json!({"alias_type": 3, "token_type": token_type, "value": hex(value)}),
}
}
fn location_filter(f: &LocationFilter) -> Value {
let mut m = Map::new();
m.insert("filter_type".into(), json!(f.type_code()));
let start = |l: &Location, m: &mut Map<String, Value>| {
m.insert("start_group".into(), json!(l.group));
m.insert("start_object".into(), json!(l.object));
};
match f {
LocationFilter::None | LocationFilter::NextObject => {}
LocationFilter::RelativeStart { start_group } => {
m.insert("start_group".into(), json!(start_group));
}
LocationFilter::AbsoluteStart { start: s } => start(s, &mut m),
LocationFilter::AbsoluteStartGroupEnd { start: s, end_group_delta } => {
start(s, &mut m);
m.insert("end_group_delta".into(), json!(end_group_delta));
}
LocationFilter::AbsoluteRange { start: s, end_group_delta, end_object } => {
start(s, &mut m);
m.insert("end_group_delta".into(), json!(end_group_delta));
m.insert("end_object".into(), json!(end_object));
}
}
Value::Object(m)
}
pub fn parameter(p: &Parameter) -> Value {
let name = param::name(p.type_code()).unwrap_or("?");
let mut m = Map::new();
m.insert("type".into(), json!(name));
match p {
Parameter::ObjectDeliveryTimeout(v)
| Parameter::RendezvousTimeout(v)
| Parameter::SubgroupDeliveryTimeout(v)
| Parameter::Expires(v)
| Parameter::FillTimeout(v)
| Parameter::NewGroupRequest(v) => {
m.insert("value".into(), json!(v));
}
Parameter::AuthorizationToken(t) => {
m.insert("token".into(), token(t));
}
Parameter::LargestObject(l) => {
m.insert("location".into(), loc(l));
}
Parameter::Forward(b) | Parameter::IncludeProperties(b) => {
m.insert("value".into(), json!(*b as u8));
}
Parameter::SubscriberPriority(v) => {
m.insert("value".into(), json!(v));
}
Parameter::GroupOrder(g) => {
m.insert("value".into(), json!(*g as u8));
}
Parameter::LocationFilter(f) => {
m.insert("filter".into(), location_filter(f));
}
Parameter::FillParameters(ps) => {
m.insert("parameters".into(), parameters(ps));
}
Parameter::RangeFilter { filter, .. } => {
let f = match filter {
None => Value::Null,
Some(f) => {
let mut fm = Map::new();
fm.insert("set_id".into(), json!(f.set_id));
if let Some(t) = f.property_type {
fm.insert("property_type".into(), json!(t));
}
fm.insert("ranges".into(), Value::Array(f.ranges.iter().map(|r| json!([r.start, r.end])).collect()));
Value::Object(fm)
}
};
m.insert("filter".into(), f);
}
Parameter::TrackNamespacePrefix(n) => {
m.insert("namespace".into(), ns(n));
}
}
Value::Object(m)
}
pub fn parameters(ps: &Parameters) -> Value {
Value::Array(ps.0.iter().map(parameter).collect())
}
fn track_request(r: &TrackRequest) -> Map<String, Value> {
let mut m = Map::new();
m.insert("request_id".into(), json!(r.request_id));
m.insert("namespace".into(), ns(&r.namespace));
m.insert("name".into(), json!(hex(&r.name.0)));
m.insert("parameters".into(), parameters(&r.params));
m
}
fn ns_request(r: &NamespaceRequest) -> Map<String, Value> {
let mut m = Map::new();
m.insert("request_id".into(), json!(r.request_id));
m.insert("prefix".into(), ns(&r.prefix));
m.insert("parameters".into(), parameters(&r.params));
m
}
pub fn control(c: &ControlMessage) -> Value {
let mut m = match c {
ControlMessage::Setup(s) => {
let mut m = Map::new();
m.insert("options".into(), pairs(&s.options.0));
m
}
ControlMessage::Goaway(g) => {
let mut m = Map::new();
m.insert("new_session_uri".into(), json!(hex(&g.new_session_uri)));
m.insert("timeout_ms".into(), json!(g.timeout_ms));
m
}
ControlMessage::Subscribe(r) | ControlMessage::Fetch(r) | ControlMessage::TrackStatus(r) => track_request(r),
ControlMessage::SubscribeOk(s) => {
let mut m = Map::new();
m.insert("track_alias".into(), json!(s.track_alias));
m.insert("parameters".into(), parameters(&s.params));
m.insert("track_properties".into(), properties(&s.track_properties));
m
}
ControlMessage::PublishStateNotify(p) => {
let mut m = Map::new();
m.insert("parameters".into(), parameters(p));
m
}
ControlMessage::Publish(p) => {
let mut m = Map::new();
m.insert("request_id".into(), json!(p.request_id));
m.insert("namespace".into(), ns(&p.namespace));
m.insert("name".into(), json!(hex(&p.name.0)));
m.insert("track_alias".into(), json!(p.track_alias));
m.insert("parameters".into(), parameters(&p.params));
m.insert("track_properties".into(), properties(&p.track_properties));
m
}
ControlMessage::PublishDone(d) => {
let mut m = Map::new();
m.insert("status".into(), json!(d.status.0));
m.insert("stream_count".into(), json!(d.stream_count));
m.insert("reason".into(), json!(hex(&d.reason)));
m
}
ControlMessage::FetchOk(f) => {
let mut m = Map::new();
m.insert("end_of_track".into(), json!(f.end_of_track as u8));
m.insert("end_location".into(), loc(&f.end_location));
m.insert("parameters".into(), parameters(&f.params));
m.insert("track_properties".into(), properties(&f.track_properties));
m
}
ControlMessage::PublishNamespace(r) | ControlMessage::SubscribeNamespace(r) | ControlMessage::SubscribeTracks(r) => ns_request(r),
ControlMessage::Namespace(n) | ControlMessage::NamespaceDone(n) => {
let mut m = Map::new();
m.insert("suffix".into(), ns(n));
m
}
ControlMessage::PublishSkipped(s) => {
let mut m = Map::new();
m.insert("suffix".into(), ns(&s.suffix));
m.insert("name".into(), json!(hex(&s.name.0)));
m
}
ControlMessage::RequestUpdate(u) => {
let mut m = Map::new();
m.insert("request_id".into(), json!(u.request_id));
m.insert("parameters".into(), parameters(&u.params));
m
}
ControlMessage::RequestOk(o) => {
let mut m = Map::new();
m.insert("parameters".into(), parameters(&o.params));
m.insert("track_properties".into(), properties(&o.track_properties));
m
}
ControlMessage::RequestError(e) => {
let mut m = Map::new();
m.insert("code".into(), json!(e.code.0));
m.insert("retry_interval".into(), json!(e.retry_interval));
m.insert("reason".into(), json!(hex(&e.reason)));
m.insert(
"redirect".into(),
match &e.redirect {
None => Value::Null,
Some(r) => json!({"connect_uri": hex(&r.connect_uri), "namespace": ns(&r.namespace), "name": hex(&r.name.0)}),
},
);
m
}
};
m.insert("message".into(), json!(msg::name(c.type_code()).unwrap_or("?")));
Value::Object(m)
}
pub fn uni_event(e: &UniEvent) -> Value {
match e {
UniEvent::Header(StreamHeader::Control) => json!({"header": "control"}),
UniEvent::Header(StreamHeader::Padding) => json!({"header": "padding"}),
UniEvent::Header(StreamHeader::Fetch(f)) => json!({"header": "fetch", "request_id": f.request_id}),
UniEvent::Header(StreamHeader::Subgroup(h)) => {
let (mode, sid) = match h.subgroup_id {
SubgroupId::Zero => (0, Value::Null),
SubgroupId::FirstObjectId => (1, Value::Null),
SubgroupId::Explicit(s) => (2, json!(s)),
};
json!({
"header": "subgroup",
"track_alias": h.track_alias,
"group_id": h.group_id,
"subgroup_id_mode": mode,
"subgroup_id": sid,
"publisher_priority": h.publisher_priority,
"properties": h.properties,
"end_of_group": h.end_of_group,
"first_object": h.first_object,
})
}
UniEvent::Control(c) => json!({"control": control(c)}),
UniEvent::SubgroupObject(o) => json!({"object": {
"object_id": o.object_id,
"properties": properties(&o.properties),
"status": o.status.code(),
"payload": hex(&o.payload),
}}),
UniEvent::FetchEntry(FetchEntry::Object(o)) => json!({"fetch_object": {
"group_id": o.group_id,
"object_id": o.object_id,
"subgroup_id": o.subgroup_id,
"publisher_priority": o.publisher_priority,
"properties": o.properties.as_ref().map(properties),
"payload": hex(&o.payload),
}}),
UniEvent::FetchEntry(FetchEntry::EndOfRange { kind, location }) => json!({"end_of_range": {
"kind": match kind { RangeEnd::NonExistent => "non-existent", RangeEnd::Unknown => "unknown", RangeEnd::TimedOut => "timed-out" },
"location": loc(location),
}}),
UniEvent::Padding(n) => json!({"padding": n}),
}
}
pub fn datagram(d: &Datagram) -> Value {
match d {
Datagram::Padding(n) => json!({"padding": n}),
Datagram::Object(o) => {
let (status, payload) = match &o.content {
DatagramContent::Status(s) => (json!(s.code()), Value::Null),
DatagramContent::Payload(p) => (Value::Null, json!(hex(p))),
};
json!({"datagram": {
"track_alias": o.track_alias,
"group_id": o.group_id,
"object_id": o.object_id,
"publisher_priority": o.publisher_priority,
"properties": o.properties.as_ref().map(properties),
"end_of_group": o.end_of_group,
"status": status,
"payload": payload,
}})
}
}
}
/// A refusal: its kind and what the draft says follows.
pub fn error(e: &WireError) -> Value {
let consequence = match e.consequence() {
Consequence::CloseSession(c) => format!("session:{c}"),
Consequence::RejectRequest(c) => format!("request:{c}"),
Consequence::MalformedTrack => "malformed-track".into(),
Consequence::ResetStream(c) => format!("reset:{c}"),
};
json!({"error": e.kind(), "consequence": consequence})
}

View file

@ -0,0 +1,405 @@
//! Key-Value-Pairs (§8.3): Track and Object Properties (§8.4, §10, §11.1.2)
//! and Setup Options (§9.1).
//!
//! A pair's type is delta-encoded from the previous pair's, so a list is in
//! non-decreasing type order on the wire; an even type carries one varint, an
//! odd type a length of at most 2^16-1 and that many bytes.
use super::error::{WireError, WireResult};
use super::io::{put_varint, Reader};
use super::token::Token;
/// The largest value of an odd-typed pair (§8.3).
pub const MAX_KVP_VALUE_LEN: u64 = 0xffff;
/// A pair's value: a varint for an even type, bytes for an odd one.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum KvValue {
Varint(u64),
Bytes(Vec<u8>),
}
/// One Key-Value-Pair.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct KeyValuePair {
pub ty: u64,
pub value: KvValue,
}
impl KeyValuePair {
pub fn varint(ty: u64, v: u64) -> Self {
KeyValuePair { ty, value: KvValue::Varint(v) }
}
pub fn bytes(ty: u64, b: impl Into<Vec<u8>>) -> Self {
KeyValuePair { ty, value: KvValue::Bytes(b.into()) }
}
pub fn as_varint(&self) -> Option<u64> {
match self.value {
KvValue::Varint(v) => Some(v),
KvValue::Bytes(_) => None,
}
}
pub fn as_bytes(&self) -> Option<&[u8]> {
match &self.value {
KvValue::Bytes(b) => Some(b),
KvValue::Varint(_) => None,
}
}
}
/// Encode `pairs` in non-decreasing type order (a stable sort: pairs of one
/// type keep their order). Refused where a value's form does not match its
/// type's parity or a value is longer than 2^16-1.
pub fn encode_pairs(pairs: &[KeyValuePair], out: &mut Vec<u8>) -> WireResult<()> {
let mut sorted: Vec<&KeyValuePair> = pairs.iter().collect();
sorted.sort_by_key(|p| p.ty);
let mut prev = 0u64;
for p in sorted {
put_varint(out, p.ty - prev);
prev = p.ty;
match (&p.value, p.ty % 2) {
(KvValue::Varint(v), 0) => put_varint(out, *v),
(KvValue::Bytes(b), 1) => {
if b.len() as u64 > MAX_KVP_VALUE_LEN {
return Err(WireError::LengthExceedsLimit { what: "key-value pair", len: b.len() as u64, limit: MAX_KVP_VALUE_LEN });
}
put_varint(out, b.len() as u64);
out.extend_from_slice(b);
}
_ => return Err(WireError::Unencodable { what: "key-value pair", reason: "an even type carries a varint, an odd type bytes" }),
}
}
Ok(())
}
/// Decode pairs until `r` is empty.
pub fn decode_pairs(r: &mut Reader<'_>, what: &'static str) -> WireResult<Vec<KeyValuePair>> {
let mut out = Vec::new();
let mut prev = 0u64;
while !r.is_empty() {
let delta = r.varint(what)?;
let ty = prev.checked_add(delta).ok_or(WireError::TypeOverflow { what })?;
prev = ty;
let value = if ty % 2 == 0 {
KvValue::Varint(r.varint(what)?)
} else {
KvValue::Bytes(r.length_prefixed(Some(MAX_KVP_VALUE_LEN), what)?.to_vec())
};
out.push(KeyValuePair { ty, value });
}
Ok(out)
}
// ── Properties (§8.4, §10, §16.8) ───────────────────────────────────────────
/// Property types this draft defines (§10, Table 15).
pub mod prop {
pub const OBJECT_DELIVERY_TIMEOUT: u64 = 0x02;
pub const MAX_CACHE_DURATION: u64 = 0x04;
pub const SUBGROUP_DELIVERY_TIMEOUT: u64 = 0x06;
pub const IMMUTABLE_PROPERTIES: u64 = 0x0b;
pub const DEFAULT_PUBLISHER_PRIORITY: u64 = 0x0e;
pub const DEFAULT_PUBLISHER_GROUP_ORDER: u64 = 0x22;
pub const DYNAMIC_GROUPS: u64 = 0x30;
pub const PRIOR_GROUP_ID_GAP: u64 = 0x3c;
pub const PRIOR_OBJECT_ID_GAP: u64 = 0x3e;
/// Mandatory Track Properties (§3.7).
pub const MANDATORY_TRACK: std::ops::RangeInclusive<u64> = 0x4000..=0x7fff;
/// Ranges an application uses without registration (§8.4).
pub const APPLICATION_1BYTE: std::ops::RangeInclusive<u64> = 0x78..=0x7f;
pub const APPLICATION_2BYTE: std::ops::RangeInclusive<u64> = 0x3800..=0x3fff;
}
/// A list of Track or Object Properties: the mutable list as it is on the wire.
/// An Immutable Properties pair (type 0x0B) holds a list of its own;
/// [`Properties::immutable`] reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Properties(pub Vec<KeyValuePair>);
impl Properties {
pub fn new() -> Self {
Properties(Vec::new())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Add a pair (order on the wire is by type; see [`encode_pairs`]).
pub fn push(&mut self, p: KeyValuePair) -> &mut Self {
self.0.push(p);
self
}
/// Set the Immutable Properties pair to `inner`, replacing one already present.
pub fn set_immutable(&mut self, inner: &Properties) -> WireResult<&mut Self> {
let mut b = Vec::new();
encode_pairs(&inner.0, &mut b)?;
self.0.retain(|p| p.ty != prop::IMMUTABLE_PROPERTIES);
self.0.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
Ok(self)
}
pub fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
encode_pairs(&self.0, out)
}
pub fn to_bytes(&self) -> WireResult<Vec<u8>> {
let mut out = Vec::new();
self.encode(&mut out)?;
Ok(out)
}
/// Decode a whole byte string as a property list.
pub fn decode(b: &[u8], what: &'static str) -> WireResult<Self> {
Ok(Properties(decode_pairs(&mut Reader::new(b), what)?))
}
/// The contents of the Immutable Properties pair: `None` when there is none,
/// and an error when they do not parse (§10.7: a malformed track).
pub fn immutable(&self) -> WireResult<Option<Properties>> {
match self.0.iter().find(|p| p.ty == prop::IMMUTABLE_PROPERTIES).and_then(KeyValuePair::as_bytes) {
None => Ok(None),
Some(b) => Properties::decode(b, "immutable properties")
.map(Some)
.map_err(|_| WireError::MalformedTrack { reason: "immutable properties do not parse" }),
}
}
/// Every pair of type `ty`, from the mutable list and the immutable one
/// (§10.7: "processors MUST search both").
pub fn find_all(&self, ty: u64) -> WireResult<Vec<KeyValuePair>> {
let mut out: Vec<KeyValuePair> = self.0.iter().filter(|p| p.ty == ty).cloned().collect();
if let Some(inner) = self.immutable()? {
out.extend(inner.0.into_iter().filter(|p| p.ty == ty));
}
Ok(out)
}
/// The first pair of type `ty`, mutable list first.
pub fn find(&self, ty: u64) -> WireResult<Option<KeyValuePair>> {
Ok(self.find_all(ty)?.into_iter().next())
}
/// Both lists, the mutable one first, without the Immutable Properties pair.
pub fn flattened(&self) -> WireResult<Vec<KeyValuePair>> {
let mut out: Vec<KeyValuePair> = self.0.iter().filter(|p| p.ty != prop::IMMUTABLE_PROPERTIES).cloned().collect();
if let Some(inner) = self.immutable()? {
out.extend(inner.0);
}
Ok(out)
}
/// The checks an Object's properties must pass on their own (§3.7,
/// §10.7-10.9), given the object's Group ID and Object ID. Each failure is a
/// malformed track. In order: one Immutable Properties pair at most; its
/// contents parse and hold no Immutable Properties; no Mandatory Track
/// Property; one Prior Group ID Gap and one Prior Object ID Gap at most, each
/// no larger than the ID it precedes.
pub fn check_object(&self, group_id: u64, object_id: u64) -> WireResult<()> {
let mt = |reason| Err(WireError::MalformedTrack { reason });
if self.0.iter().filter(|p| p.ty == prop::IMMUTABLE_PROPERTIES).count() > 1 {
return mt("more than one Immutable Properties");
}
if self.immutable()?.is_some_and(|inner| inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES)) {
return mt("Immutable Properties inside Immutable Properties");
}
let all = self.flattened()?;
if all.iter().any(|p| prop::MANDATORY_TRACK.contains(&p.ty)) {
return mt("a Mandatory Track Property on an object");
}
for (ty, id, twice, larger) in [
(prop::PRIOR_GROUP_ID_GAP, group_id, "more than one Prior Group ID Gap", "a Prior Group ID Gap larger than the Group ID"),
(prop::PRIOR_OBJECT_ID_GAP, object_id, "more than one Prior Object ID Gap", "a Prior Object ID Gap larger than the Object ID"),
] {
let gaps: Vec<u64> = all.iter().filter(|p| p.ty == ty).filter_map(KeyValuePair::as_varint).collect();
if gaps.len() > 1 {
return mt(twice);
}
if gaps.first().is_some_and(|g| *g > id) {
return mt(larger);
}
}
Ok(())
}
/// The checks Track Properties must pass (§10.4-10.7): their Immutable
/// Properties parse and nest none; DEFAULT_PUBLISHER_PRIORITY at most 255;
/// DEFAULT_PUBLISHER_GROUP_ORDER 1 or 2; DYNAMIC_GROUPS 0 or 1.
pub fn check_track(&self) -> WireResult<()> {
if self.immutable()?.is_some_and(|inner| inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES)) {
return Err(WireError::MalformedTrack { reason: "Immutable Properties inside Immutable Properties" });
}
for p in self.flattened()? {
let v = p.as_varint();
match (p.ty, v) {
(prop::DEFAULT_PUBLISHER_PRIORITY, Some(v)) if v > 255 => {
return Err(WireError::InvalidValue { what: "DEFAULT_PUBLISHER_PRIORITY", value: v })
}
(prop::DEFAULT_PUBLISHER_GROUP_ORDER, Some(v)) if v != 1 && v != 2 => {
return Err(WireError::InvalidValue { what: "DEFAULT_PUBLISHER_GROUP_ORDER", value: v })
}
(prop::DYNAMIC_GROUPS, Some(v)) if v > 1 => return Err(WireError::InvalidValue { what: "DYNAMIC_GROUPS", value: v }),
_ => {}
}
}
Ok(())
}
/// The first Mandatory Track Property (§3.7) whose type is not in `known`:
/// a track carrying one MUST NOT be processed or forwarded by this endpoint.
pub fn unknown_mandatory(&self, known: &[u64]) -> WireResult<Option<u64>> {
Ok(self.flattened()?.iter().map(|p| p.ty).find(|t| prop::MANDATORY_TRACK.contains(t) && !known.contains(t)))
}
}
// ── Setup Options (§9.1, §16.4) ─────────────────────────────────────────────
/// Setup Option types this draft defines (Table 11).
pub mod opt {
pub const PATH: u64 = 0x01;
pub const AUTHORIZATION_TOKEN: u64 = 0x03;
pub const MAX_AUTH_TOKEN_CACHE_SIZE: u64 = 0x04;
pub const AUTHORITY: u64 = 0x05;
pub const MAX_FILTER_RANGES: u64 = 0x06;
pub const MOQT_IMPLEMENTATION: u64 = 0x07;
pub const MAX_REQUEST_UPDATES: u64 = 0x08;
/// Every option this draft defines.
pub const KNOWN: [u64; 7] = [PATH, AUTHORIZATION_TOKEN, MAX_AUTH_TOKEN_CACHE_SIZE, AUTHORITY, MAX_FILTER_RANGES, MOQT_IMPLEMENTATION, MAX_REQUEST_UPDATES];
}
/// The Setup Options of a SETUP message. Unknown options are kept (and, per
/// §9.1, ignored by every accessor here); a known option other than
/// AUTHORIZATION_TOKEN appears at most once.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SetupOptions(pub Vec<KeyValuePair>);
impl SetupOptions {
pub fn new() -> Self {
SetupOptions(Vec::new())
}
pub fn push(&mut self, p: KeyValuePair) -> &mut Self {
self.0.push(p);
self
}
pub fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
self.check()?;
encode_pairs(&self.0, out)
}
pub fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
let s = SetupOptions(decode_pairs(r, "setup options")?);
s.check()?;
Ok(s)
}
/// No known option repeated except AUTHORIZATION_TOKEN (§9.1, §9.1.4), and
/// every AUTHORIZATION_TOKEN a Token structure (§8.9).
fn check(&self) -> WireResult<()> {
for k in opt::KNOWN {
if k != opt::AUTHORIZATION_TOKEN && self.0.iter().filter(|p| p.ty == k).count() > 1 {
return Err(WireError::Duplicate { what: "setup option", ty: k });
}
}
for p in self.0.iter().filter(|p| p.ty == opt::AUTHORIZATION_TOKEN) {
Token::decode(p.as_bytes().unwrap_or_default())?;
}
Ok(())
}
fn bytes_of(&self, ty: u64) -> Option<&[u8]> {
self.0.iter().find(|p| p.ty == ty).and_then(KeyValuePair::as_bytes)
}
fn varint_of(&self, ty: u64) -> Option<u64> {
self.0.iter().find(|p| p.ty == ty).and_then(KeyValuePair::as_varint)
}
/// PATH (§9.1.2), as sent.
pub fn path(&self) -> Option<&[u8]> {
self.bytes_of(opt::PATH)
}
/// AUTHORITY (§9.1.1), as sent.
pub fn authority(&self) -> Option<&[u8]> {
self.bytes_of(opt::AUTHORITY)
}
/// MOQT_IMPLEMENTATION (§9.1.5), as sent.
pub fn implementation(&self) -> Option<&[u8]> {
self.bytes_of(opt::MOQT_IMPLEMENTATION)
}
/// MAX_AUTH_TOKEN_CACHE_SIZE (§9.1.3); 0 when absent.
pub fn max_auth_token_cache_size(&self) -> u64 {
self.varint_of(opt::MAX_AUTH_TOKEN_CACHE_SIZE).unwrap_or(0)
}
/// MAX_FILTER_RANGES (§9.1.6); 0 when absent, which forbids Range Filters.
pub fn max_filter_ranges(&self) -> u64 {
self.varint_of(opt::MAX_FILTER_RANGES).unwrap_or(0)
}
/// MAX_REQUEST_UPDATES (§9.1.7); 0 when absent, which is no limit.
pub fn max_request_updates(&self) -> u64 {
self.varint_of(opt::MAX_REQUEST_UPDATES).unwrap_or(0)
}
/// Every AUTHORIZATION_TOKEN (§9.1.4).
pub fn authorization_tokens(&self) -> Vec<Token> {
self.0
.iter()
.filter(|p| p.ty == opt::AUTHORIZATION_TOKEN)
.filter_map(|p| Token::decode(p.as_bytes()?).ok())
.collect()
}
/// The value of an option this draft does not define, for an extension
/// that declares itself by one (§6.3.2).
pub fn extension(&self, ty: u64) -> Option<&KeyValuePair> {
self.0.iter().find(|p| p.ty == ty)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairs_are_delta_coded_in_type_order() {
let ps = vec![KeyValuePair::bytes(0x3a41, b"ab".to_vec()), KeyValuePair::varint(0x02, 300), KeyValuePair::varint(0x02, 5)];
let mut b = Vec::new();
encode_pairs(&ps, &mut b).unwrap();
// 0x02 (delta 2) 300, delta 0 5, delta 0x3a3f then length 2.
let back = decode_pairs(&mut Reader::new(&b), "t").unwrap();
assert_eq!(back, vec![ps[1].clone(), ps[2].clone(), ps[0].clone()]);
assert_eq!(b[0], 0x02);
}
#[test]
fn the_drafts_limits_hold() {
// An odd type whose length is 2^16.
let mut b = vec![0x01];
put_varint(&mut b, 0x1_0000);
assert!(matches!(decode_pairs(&mut Reader::new(&b), "t"), Err(WireError::LengthExceedsLimit { .. })));
// A delta past 2^64-1.
let mut b = Vec::new();
put_varint(&mut b, u64::MAX);
put_varint(&mut b, 0);
put_varint(&mut b, 1);
assert_eq!(decode_pairs(&mut Reader::new(&b), "t"), Err(WireError::TypeOverflow { what: "t" }));
assert!(encode_pairs(&[KeyValuePair::varint(1, 0)], &mut Vec::new()).is_err());
}
#[test]
fn object_checks() {
let mut inner = Properties::new();
inner.push(KeyValuePair::varint(prop::PRIOR_OBJECT_ID_GAP, 2));
let mut p = Properties::new();
p.set_immutable(&inner).unwrap();
assert!(p.check_object(0, 2).is_ok());
assert!(matches!(p.check_object(0, 1), Err(WireError::MalformedTrack { .. })));
p.push(KeyValuePair::varint(prop::PRIOR_OBJECT_ID_GAP, 1));
assert!(matches!(p.check_object(0, 9), Err(WireError::MalformedTrack { .. })), "one in each list is two");
let mut q = Properties::new();
q.push(KeyValuePair::varint(0x4000, 1));
assert!(matches!(q.check_object(0, 0), Err(WireError::MalformedTrack { .. })));
assert_eq!(q.unknown_mandatory(&[]).unwrap(), Some(0x4000));
assert_eq!(q.unknown_mandatory(&[0x4000]).unwrap(), None);
}
}

View file

@ -0,0 +1,70 @@
//! The wire codec of `draft-ietf-moq-transport-22` (1 October 2026).
//!
//! Everything a -22 endpoint puts on the wire or reads from it, and nothing of
//! another draft: deployed relays speak -14, -16 and -18, and no two drafts
//! interoperate (the varint alone changed in -17). A later draft, or an earlier
//! one for interoperation, is a sibling module beside this one, chosen by
//! [`crate::draft::Draft`]; the draft-neutral data model (tracks, objects,
//! receipts) stays where it is.
//!
//! | -22 | here |
//! |---|---|
//! | §8.1 variable-length integer | [`varint`] |
//! | §8.2 Location, §8.7 Track Namespace, §8.8 rendering | [`names`] |
//! | §8.3 Key-Value-Pair, §8.4 / §10 Properties, §9.1 Setup Options | [`kvp`] |
//! | §8.9 Authorization Token | [`token`] |
//! | §9 control messages | [`control`] |
//! | §9.20 Message Parameters, §8.6 Range Filters, §9.20.9 Location Filters | [`params`] |
//! | §6.3, §6.4.2 streams and Request IDs, §9.1.7 update credit | [`request`] |
//! | §11 subgroup streams, datagrams, fetch streams, padding | [`data`] |
//! | §12, §16.11 status and error codes | [`codes`] |
//!
//! Every decoder is bounded: it reads no byte that is not there, allocates
//! nothing from a length before the bytes behind it are known to exist, and
//! refuses with a typed [`WireError`] whose [`WireError::consequence`] is the
//! draft's (close the session with a code, reject the request, a malformed
//! track).
//!
//! Where -22's text leaves an encoding open this codec takes one reading and
//! says so where it applies: FILL_TIMEOUT and RENDEZVOUS_TIMEOUT are varints
//! ([`params`]); FILL_PARAMETERS holds parameters up to its length, with no
//! count ([`params`]); an End of Range's Group ID and Object ID are the
//! delta-coded fields its flags mark ([`data::FetchCodec`]).
pub mod codes;
pub mod control;
pub mod data;
pub mod error;
pub mod io;
pub mod json;
pub mod kvp;
pub mod names;
pub mod params;
pub mod request;
pub mod token;
pub mod varint;
pub use codes::{PublishDoneCode, RequestErrorCode, SessionError, StreamResetCode};
pub use control::{
ControlMessage, Fetch, FetchOk, Goaway, NamespaceRequest, Publish, PublishDone, PublishSkipped, Redirect, RequestError, RequestOk,
RequestUpdate, Setup, Subscribe, SubscribeOk, TrackRequest, TrackStatus,
};
pub use data::{
decode_uni_stream, Datagram, DatagramContent, FetchCodec, FetchEntry, FetchHeader, FetchObject, Limits, ObjectDatagram, ObjectStatus,
RangeEnd, StreamHeader, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject, UniEvent, UniStreamDecoder,
};
pub use error::{Consequence, WireError, WireResult};
pub use kvp::{KeyValuePair, KvValue, Properties, SetupOptions};
pub use names::Location;
pub use params::{GroupOrder, LocationFilter, Parameter, Parameters, Range, RangeFilter};
pub use request::{ControlStreamState, PeerRequestIds, Perspective, RequestIds, RequestKind, RequestStream, Side};
pub use token::Token;
/// The ALPN (native QUIC) and WT-Available-Protocols value (WebTransport) of
/// this draft (§6.2): "moqt-" and the draft number.
pub const ALPN: &[u8] = b"moqt-22";
/// The conventional 32-bit code of this draft, `0xff000000 + 22`. -22 carries
/// no version in SETUP; the draft is the one the ALPN selected, and this code
/// only names it.
pub const VERSION: u32 = 0xff00_0016;

View file

@ -0,0 +1,244 @@
//! Track Namespaces and Track Names (§2.4.1, §8.7, §8.8) and Locations (§8.2).
use super::error::{WireError, WireResult};
use super::io::{put_varint, Reader};
use crate::track::{TrackName, TrackNamespace};
/// At most 32 fields in a Track Namespace (§8.7).
pub const MAX_NAMESPACE_FIELDS: u64 = 32;
/// At most 4,096 bytes in a Full Track Name: the namespace fields' lengths and
/// the track name's (§8.7).
pub const MAX_FULL_TRACK_NAME: u64 = 4096;
/// A Location: a Group ID and an Object ID (§8.2), ordered as the draft orders
/// them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct Location {
pub group: u64,
pub object: u64,
}
impl Location {
pub const fn new(group: u64, object: u64) -> Self {
Location { group, object }
}
pub fn encode(&self, out: &mut Vec<u8>) {
put_varint(out, self.group);
put_varint(out, self.object);
}
pub fn decode(r: &mut Reader<'_>, what: &'static str) -> WireResult<Self> {
Ok(Location { group: r.varint(what)?, object: r.varint(what)? })
}
}
/// The byte length of a namespace: the sum of its fields' lengths.
pub fn namespace_len(ns: &TrackNamespace) -> u64 {
ns.0.iter().map(|f| f.len() as u64).sum()
}
/// The namespace's own checks: at most 32 fields, none empty, at most 4,096
/// bytes.
pub fn check_namespace(ns: &TrackNamespace) -> WireResult<()> {
if ns.0.len() as u64 > MAX_NAMESPACE_FIELDS {
return Err(WireError::TooManyNamespaceFields(ns.0.len() as u64));
}
if ns.0.iter().any(|f| f.is_empty()) {
return Err(WireError::EmptyNamespaceField);
}
let len = namespace_len(ns);
if len > MAX_FULL_TRACK_NAME {
return Err(WireError::LengthExceedsLimit { what: "track namespace", len, limit: MAX_FULL_TRACK_NAME });
}
Ok(())
}
/// A Full Track Name's checks: the namespace's, and at most 4,096 bytes with
/// the name.
pub fn check_full_name(ns: &TrackNamespace, name: &TrackName) -> WireResult<()> {
check_namespace(ns)?;
let len = namespace_len(ns) + name.0.len() as u64;
if len > MAX_FULL_TRACK_NAME {
return Err(WireError::LengthExceedsLimit { what: "full track name", len, limit: MAX_FULL_TRACK_NAME });
}
Ok(())
}
pub fn encode_namespace(ns: &TrackNamespace, out: &mut Vec<u8>) -> WireResult<()> {
check_namespace(ns)?;
put_varint(out, ns.0.len() as u64);
for f in &ns.0 {
put_varint(out, f.len() as u64);
out.extend_from_slice(f);
}
Ok(())
}
/// Read a Track Namespace, refusing more than 32 fields before reading any, an
/// empty field, and more than 4,096 bytes.
pub fn decode_namespace(r: &mut Reader<'_>) -> WireResult<TrackNamespace> {
let n = r.varint("track namespace field count")?;
if n > MAX_NAMESPACE_FIELDS {
return Err(WireError::TooManyNamespaceFields(n));
}
let mut fields = Vec::with_capacity(n as usize);
let mut total = 0u64;
for _ in 0..n {
let len = r.varint("track namespace field length")?;
if len == 0 {
return Err(WireError::EmptyNamespaceField);
}
total = total.saturating_add(len);
if total > MAX_FULL_TRACK_NAME {
return Err(WireError::LengthExceedsLimit { what: "track namespace", len: total, limit: MAX_FULL_TRACK_NAME });
}
fields.push(r.bytes(len, "track namespace field")?.to_vec());
}
Ok(TrackNamespace(fields))
}
pub fn encode_track_name(ns: &TrackNamespace, name: &TrackName, out: &mut Vec<u8>) -> WireResult<()> {
check_full_name(ns, name)?;
put_varint(out, name.0.len() as u64);
out.extend_from_slice(&name.0);
Ok(())
}
/// Read a Track Name that follows `ns`, refusing a Full Track Name past 4,096
/// bytes before reading the name.
pub fn decode_track_name(r: &mut Reader<'_>, ns: &TrackNamespace) -> WireResult<TrackName> {
let len = r.varint("track name length")?;
let full = namespace_len(ns).saturating_add(len);
if full > MAX_FULL_TRACK_NAME {
return Err(WireError::LengthExceedsLimit { what: "full track name", len: full, limit: MAX_FULL_TRACK_NAME });
}
Ok(TrackName(r.bytes(len, "track name")?.to_vec()))
}
/// Whether `prefix` matches `ns` (§2.4.2): each of its fields equals the
/// field of `ns` at the same position.
pub fn prefix_matches(prefix: &TrackNamespace, ns: &TrackNamespace) -> bool {
prefix.0.len() <= ns.0.len() && prefix.0.iter().zip(&ns.0).all(|(a, b)| a == b)
}
/// Whether two prefixes share a common prefix in §4.2's sense: one matches the
/// other. Such a pair is refused with PREFIX_OVERLAP.
pub fn prefixes_overlap(a: &TrackNamespace, b: &TrackNamespace) -> bool {
prefix_matches(a, b) || prefix_matches(b, a)
}
/// The namespace `.session` (§6.5) and the reserved first field `.` (§2.4.3).
pub const SESSION_NAMESPACE: &[u8] = b".session";
/// Whether the namespace is reserved (§2.4.3: its first field begins with a
/// period).
pub fn is_reserved(ns: &TrackNamespace) -> bool {
ns.0.first().is_some_and(|f| f.first() == Some(&b'.'))
}
fn render_field(f: &[u8], out: &mut String) {
for &b in f {
if b.is_ascii_alphanumeric() || b == b'_' {
out.push(b as char);
} else {
out.push_str(&format!(".{b:02x}"));
}
}
}
/// §8.8's rendering: fields joined by `-`, then `--` and the track name; bytes
/// other than `a-z A-Z 0-9 _` as `.` and two lowercase hex digits.
pub fn render(ns: &TrackNamespace, name: Option<&TrackName>) -> String {
let mut s = String::new();
for (i, f) in ns.0.iter().enumerate() {
if i > 0 {
s.push('-');
}
render_field(f, &mut s);
}
if let Some(n) = name {
s.push_str("--");
render_field(&n.0, &mut s);
}
s
}
fn parse_field(s: &str) -> Option<Vec<u8>> {
let b = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
let c = b[i];
if c.is_ascii_alphanumeric() || c == b'_' {
out.push(c);
i += 1;
} else if c == b'.' && b.len() >= i + 3 {
let nib = |x: u8| match x {
b'0'..=b'9' => Some(x - b'0'),
b'a'..=b'f' => Some(x - b'a' + 10),
_ => None,
};
let v = (nib(b[i + 1])? << 4) | nib(b[i + 2])?;
// A byte that has a literal form must use it (§8.8).
if v.is_ascii_alphanumeric() || v == b'_' {
return None;
}
out.push(v);
i += 3;
} else {
return None;
}
}
Some(out)
}
/// Parse §8.8's rendering, refusing any rendering that is not the one form of
/// its bytes. Returns the namespace and, after `--`, the track name.
pub fn parse_rendered(s: &str) -> Option<(TrackNamespace, Option<TrackName>)> {
let (ns_part, name_part) = match s.find("--") {
Some(i) => (&s[..i], Some(&s[i + 2..])),
None => (s, None),
};
let fields = if ns_part.is_empty() {
vec![]
} else {
ns_part.split('-').map(parse_field).collect::<Option<Vec<_>>>()?
};
let name = match name_part {
Some(n) => Some(TrackName(parse_field(n)?)),
None => None,
};
Some((TrackNamespace(fields), name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_drafts_rendering_example() {
let ns = TrackNamespace::from_strs(["example.net", "team2", "project_x"]);
let name = TrackName::from_str("report");
let s = render(&ns, Some(&name));
assert_eq!(s, "example.2enet-team2-project_x--report");
assert_eq!(parse_rendered(&s), Some((ns, Some(name))));
assert_eq!(parse_rendered(".61"), None, "a literal byte hex-encoded");
assert_eq!(parse_rendered(".2"), None);
assert_eq!(parse_rendered(".2E"), None, "uppercase hex");
}
#[test]
fn namespace_limits() {
let mut b = Vec::new();
put_varint(&mut b, 33);
assert_eq!(decode_namespace(&mut Reader::new(&b)), Err(WireError::TooManyNamespaceFields(33)));
assert_eq!(decode_namespace(&mut Reader::new(&[1, 0])), Err(WireError::EmptyNamespaceField));
let ns = TrackNamespace(vec![vec![b'a'; 4096]]);
assert!(check_namespace(&ns).is_ok());
assert!(check_full_name(&ns, &TrackName::from_str("x")).is_err());
assert!(prefix_matches(&TrackNamespace::from_strs(["foo"]), &TrackNamespace::from_strs(["foo", "bar"])));
assert!(!prefix_matches(&TrackNamespace::from_strs(["foobar"]), &TrackNamespace::from_strs(["foo", "bar"])));
assert!(prefix_matches(&TrackNamespace(vec![]), &TrackNamespace::from_strs(["x"])));
}
}

View file

@ -0,0 +1,832 @@
//! Message Parameters (§9.20, Table 14).
//!
//! A parameter is a type, delta-encoded from the previous one, and a value in
//! the form its definition gives; the receiver must understand every one
//! (§9.20: an unknown parameter closes the session), so a block is bounded by a
//! count rather than a length. Two parameters' encodings are not stated in -22's
//! prose: FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04), both durations in
//! milliseconds. This crate reads both as a varint, as the draft encodes its
//! other durations.
use super::error::{WireError, WireResult};
use super::io::{put_varint, Reader};
use super::names::{decode_namespace, encode_namespace, Location};
use super::token::Token;
use crate::track::TrackNamespace;
/// Parameter types (Table 14).
pub mod param {
pub const OBJECT_DELIVERY_TIMEOUT: u64 = 0x02;
pub const AUTHORIZATION_TOKEN: u64 = 0x03;
pub const RENDEZVOUS_TIMEOUT: u64 = 0x04;
pub const SUBGROUP_DELIVERY_TIMEOUT: u64 = 0x06;
pub const EXPIRES: u64 = 0x08;
pub const LARGEST_OBJECT: u64 = 0x09;
pub const FILL_TIMEOUT: u64 = 0x0a;
pub const FORWARD: u64 = 0x10;
pub const SUBSCRIBER_PRIORITY: u64 = 0x20;
pub const LOCATION_FILTER: u64 = 0x21;
pub const GROUP_ORDER: u64 = 0x22;
pub const FILL_PARAMETERS: u64 = 0x23;
pub const SUBGROUP_FILTER: u64 = 0x25;
pub const OBJECTID_FILTER: u64 = 0x26;
pub const PRIORITY_FILTER: u64 = 0x27;
pub const OBJECT_PROPERTY_FILTER: u64 = 0x28;
pub const TRACK_PROPERTY_FILTER: u64 = 0x29;
pub const NEW_GROUP_REQUEST: u64 = 0x32;
pub const TRACK_NAMESPACE_PREFIX: u64 = 0x34;
pub const INCLUDE_PROPERTIES: u64 = 0x35;
/// Every parameter type this draft defines.
pub const KNOWN: [u64; 20] = [
OBJECT_DELIVERY_TIMEOUT,
AUTHORIZATION_TOKEN,
RENDEZVOUS_TIMEOUT,
SUBGROUP_DELIVERY_TIMEOUT,
EXPIRES,
LARGEST_OBJECT,
FILL_TIMEOUT,
FORWARD,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
GROUP_ORDER,
FILL_PARAMETERS,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
TRACK_PROPERTY_FILTER,
NEW_GROUP_REQUEST,
TRACK_NAMESPACE_PREFIX,
INCLUDE_PROPERTIES,
];
/// The Range Filters (§3.3.2), which may repeat with distinct keys.
pub fn is_range_filter(t: u64) -> bool {
(SUBGROUP_FILTER..=TRACK_PROPERTY_FILTER).contains(&t)
}
/// The name the draft gives the type.
pub fn name(t: u64) -> Option<&'static str> {
Some(match t {
OBJECT_DELIVERY_TIMEOUT => "OBJECT_DELIVERY_TIMEOUT",
AUTHORIZATION_TOKEN => "AUTHORIZATION_TOKEN",
RENDEZVOUS_TIMEOUT => "RENDEZVOUS_TIMEOUT",
SUBGROUP_DELIVERY_TIMEOUT => "SUBGROUP_DELIVERY_TIMEOUT",
EXPIRES => "EXPIRES",
LARGEST_OBJECT => "LARGEST_OBJECT",
FILL_TIMEOUT => "FILL_TIMEOUT",
FORWARD => "FORWARD",
SUBSCRIBER_PRIORITY => "SUBSCRIBER_PRIORITY",
LOCATION_FILTER => "LOCATION_FILTER",
GROUP_ORDER => "GROUP_ORDER",
FILL_PARAMETERS => "FILL_PARAMETERS",
SUBGROUP_FILTER => "SUBGROUP_FILTER",
OBJECTID_FILTER => "OBJECTID_FILTER",
PRIORITY_FILTER => "PRIORITY_FILTER",
OBJECT_PROPERTY_FILTER => "OBJECT_PROPERTY_FILTER",
TRACK_PROPERTY_FILTER => "TRACK_PROPERTY_FILTER",
NEW_GROUP_REQUEST => "NEW_GROUP_REQUEST",
TRACK_NAMESPACE_PREFIX => "TRACK_NAMESPACE_PREFIX",
INCLUDE_PROPERTIES => "INCLUDE_PROPERTIES",
_ => return None,
})
}
}
/// Where a parameter block sits, and so which parameters it may hold (§9.20.1:
/// each message lists them).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ParamContext {
pub name: &'static str,
pub allowed: &'static [u64],
}
pub mod ctx {
use super::param::*;
use super::ParamContext;
pub const SUBSCRIBE: ParamContext = ParamContext {
name: "SUBSCRIBE",
allowed: &[
OBJECT_DELIVERY_TIMEOUT,
AUTHORIZATION_TOKEN,
RENDEZVOUS_TIMEOUT,
SUBGROUP_DELIVERY_TIMEOUT,
FORWARD,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
GROUP_ORDER,
FILL_PARAMETERS,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
NEW_GROUP_REQUEST,
INCLUDE_PROPERTIES,
],
};
pub const SUBSCRIBE_OK: ParamContext = ParamContext { name: "SUBSCRIBE_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
pub const PUBLISH: ParamContext = ParamContext {
name: "PUBLISH",
allowed: &[
OBJECT_DELIVERY_TIMEOUT,
AUTHORIZATION_TOKEN,
SUBGROUP_DELIVERY_TIMEOUT,
EXPIRES,
LARGEST_OBJECT,
FORWARD,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
GROUP_ORDER,
],
};
pub const PUBLISH_STATE_NOTIFY: ParamContext =
ParamContext { name: "PUBLISH_STATE_NOTIFY", allowed: &[LARGEST_OBJECT, FORWARD, LOCATION_FILTER] };
pub const FETCH: ParamContext = ParamContext {
name: "FETCH",
allowed: &[
AUTHORIZATION_TOKEN,
FILL_TIMEOUT,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
GROUP_ORDER,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
INCLUDE_PROPERTIES,
],
};
pub const FETCH_OK: ParamContext = ParamContext { name: "FETCH_OK", allowed: &[] };
pub const TRACK_STATUS: ParamContext = ParamContext { name: "TRACK_STATUS", allowed: &[AUTHORIZATION_TOKEN, INCLUDE_PROPERTIES] };
pub const PUBLISH_NAMESPACE: ParamContext = ParamContext { name: "PUBLISH_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
pub const SUBSCRIBE_NAMESPACE: ParamContext = ParamContext { name: "SUBSCRIBE_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
pub const SUBSCRIBE_TRACKS: ParamContext = ParamContext {
name: "SUBSCRIBE_TRACKS",
allowed: &[
AUTHORIZATION_TOKEN,
FORWARD,
GROUP_ORDER,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
TRACK_PROPERTY_FILTER,
INCLUDE_PROPERTIES,
],
};
/// REQUEST_UPDATE, read before its request is known: the union of the lists
/// of §9.5. [`super::Parameters::check_allowed`] narrows it.
pub const REQUEST_UPDATE: ParamContext = ParamContext {
name: "REQUEST_UPDATE",
allowed: &[
OBJECT_DELIVERY_TIMEOUT,
AUTHORIZATION_TOKEN,
SUBGROUP_DELIVERY_TIMEOUT,
FORWARD,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
FILL_PARAMETERS,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
TRACK_PROPERTY_FILTER,
NEW_GROUP_REQUEST,
TRACK_NAMESPACE_PREFIX,
],
};
pub const UPDATE_SUBSCRIPTION: ParamContext = ParamContext {
name: "REQUEST_UPDATE for a subscription",
allowed: &[
OBJECT_DELIVERY_TIMEOUT,
AUTHORIZATION_TOKEN,
SUBGROUP_DELIVERY_TIMEOUT,
FORWARD,
SUBSCRIBER_PRIORITY,
LOCATION_FILTER,
FILL_PARAMETERS,
SUBGROUP_FILTER,
OBJECTID_FILTER,
PRIORITY_FILTER,
OBJECT_PROPERTY_FILTER,
NEW_GROUP_REQUEST,
],
};
pub const UPDATE_FETCH: ParamContext = ParamContext { name: "REQUEST_UPDATE for a FETCH", allowed: &[AUTHORIZATION_TOKEN, SUBSCRIBER_PRIORITY] };
pub const UPDATE_PUBLISH_NAMESPACE: ParamContext = ParamContext { name: "REQUEST_UPDATE for a PUBLISH_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
pub const UPDATE_SUBSCRIBE_NAMESPACE: ParamContext =
ParamContext { name: "REQUEST_UPDATE for a SUBSCRIBE_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN, TRACK_NAMESPACE_PREFIX] };
pub const UPDATE_SUBSCRIBE_TRACKS: ParamContext = ParamContext {
name: "REQUEST_UPDATE for a SUBSCRIBE_TRACKS",
allowed: &[AUTHORIZATION_TOKEN, FORWARD, TRACK_PROPERTY_FILTER, TRACK_NAMESPACE_PREFIX],
};
/// REQUEST_OK, read before its request is known: the union of §9.3's lists.
pub const REQUEST_OK: ParamContext = ParamContext { name: "REQUEST_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
pub const PUBLISH_OK: ParamContext = ParamContext { name: "PUBLISH_OK", allowed: &[EXPIRES] };
pub const REQUEST_UPDATE_OK: ParamContext = ParamContext { name: "REQUEST_UPDATE_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
pub const TRACK_STATUS_OK: ParamContext = ParamContext { name: "TRACK_STATUS_OK", allowed: &[LARGEST_OBJECT] };
pub const NAMESPACE_OK: ParamContext =
ParamContext { name: "SUBSCRIBE_NAMESPACE_OK, SUBSCRIBE_TRACKS_OK or PUBLISH_NAMESPACE_OK", allowed: &[EXPIRES] };
/// Inside FILL_PARAMETERS (§9.20.15, Table 7).
pub const FILL_PARAMETERS_INNER: ParamContext = ParamContext {
name: "FILL_PARAMETERS",
allowed: &[FILL_TIMEOUT, SUBSCRIBER_PRIORITY, LOCATION_FILTER, GROUP_ORDER, SUBGROUP_FILTER, OBJECTID_FILTER, PRIORITY_FILTER, OBJECT_PROPERTY_FILTER],
};
}
/// Group Order (§9.20.8, §10.5).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum GroupOrder {
Ascending = 1,
Descending = 2,
}
impl GroupOrder {
pub fn from_u8(v: u8, what: &'static str) -> WireResult<Self> {
match v {
1 => Ok(GroupOrder::Ascending),
2 => Ok(GroupOrder::Descending),
_ => Err(WireError::InvalidValue { what, value: v as u64 }),
}
}
}
/// A Location Filter (§3.3.1, §9.20.9, Table 6), as it is on the wire.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LocationFilter {
/// 0x00: no filter.
None,
/// 0x01: from `{Largest Object.Group + 1 - start_group, 0}`, open-ended.
RelativeStart { start_group: u64 },
/// 0x02: from `start`, open-ended.
AbsoluteStart { start: Location },
/// 0x03: from `start` to the last object of group `start.group + end_group_delta`.
AbsoluteStartGroupEnd { start: Location, end_group_delta: u64 },
/// 0x04: from `start` to `{start.group + end_group_delta, end_object}`.
AbsoluteRange { start: Location, end_group_delta: u64, end_object: u64 },
/// 0x05: from the Next Object, open-ended.
NextObject,
}
impl LocationFilter {
pub fn type_code(&self) -> u64 {
match self {
LocationFilter::None => 0,
LocationFilter::RelativeStart { .. } => 1,
LocationFilter::AbsoluteStart { .. } => 2,
LocationFilter::AbsoluteStartGroupEnd { .. } => 3,
LocationFilter::AbsoluteRange { .. } => 4,
LocationFilter::NextObject => 5,
}
}
/// The absolute end group of a filter that has one.
pub fn end_group(&self) -> Option<u64> {
match self {
LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta } | LocationFilter::AbsoluteRange { start, end_group_delta, .. } => {
start.group.checked_add(*end_group_delta)
}
_ => None,
}
}
/// The start of a relative filter given the Largest Object's group, clamped
/// to 0 and to 2^64-1 as §9.20.9 says.
pub fn relative_start(start_group: u64, largest_group: Option<u64>) -> u64 {
let next = largest_group.map_or(0u128, |g| g as u128 + 1);
next.saturating_sub(start_group as u128).min(u64::MAX as u128) as u64
}
fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
if self.end_group().is_none() && matches!(self, LocationFilter::AbsoluteStartGroupEnd { .. } | LocationFilter::AbsoluteRange { .. }) {
return Err(WireError::Unencodable { what: "LOCATION_FILTER", reason: "StartGroup + EndGroupDelta past 2^64-1" });
}
put_varint(out, self.type_code());
match self {
LocationFilter::None | LocationFilter::NextObject => {}
LocationFilter::RelativeStart { start_group } => put_varint(out, *start_group),
LocationFilter::AbsoluteStart { start } => start.encode(out),
LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta } => {
start.encode(out);
put_varint(out, *end_group_delta);
}
LocationFilter::AbsoluteRange { start, end_group_delta, end_object } => {
start.encode(out);
put_varint(out, *end_group_delta);
put_varint(out, *end_object);
}
}
Ok(())
}
fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
const W: &str = "LOCATION_FILTER";
let t = r.varint(W)?;
let f = match t {
0 => LocationFilter::None,
1 => LocationFilter::RelativeStart { start_group: r.varint(W)? },
2 => LocationFilter::AbsoluteStart { start: Location::decode(r, W)? },
3 => LocationFilter::AbsoluteStartGroupEnd { start: Location::decode(r, W)?, end_group_delta: r.varint(W)? },
4 => LocationFilter::AbsoluteRange { start: Location::decode(r, W)?, end_group_delta: r.varint(W)?, end_object: r.varint(W)? },
5 => LocationFilter::NextObject,
v => return Err(WireError::InvalidValue { what: "Location Filter Type", value: v }),
};
if matches!(f, LocationFilter::AbsoluteStartGroupEnd { .. } | LocationFilter::AbsoluteRange { .. }) && f.end_group().is_none() {
return Err(WireError::Overflow { what: "LOCATION_FILTER end group" });
}
Ok(f)
}
}
/// One inclusive Range of a Range Filter (§8.6): `end` is `None` only for an
/// open-ended last range.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Range {
pub start: u64,
pub end: Option<u64>,
}
/// The body of a Range Filter parameter of Length > 0 (§8.6, §9.20.10-14):
/// its SetID, the property type for the two property filters, and its ranges
/// as absolute values.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RangeFilter {
pub set_id: u8,
/// Present exactly for OBJECT_PROPERTY_FILTER and TRACK_PROPERTY_FILTER.
pub property_type: Option<u64>,
pub ranges: Vec<Range>,
}
impl RangeFilter {
fn encode_body(&self, ty: u64, out: &mut Vec<u8>) -> WireResult<()> {
let is_prop = ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER;
if is_prop != self.property_type.is_some() {
return Err(WireError::Unencodable { what: "range filter", reason: "a property type exactly on the property filters" });
}
out.push(self.set_id);
if let Some(p) = self.property_type {
put_varint(out, p);
}
let mut prev_end = 0u64;
for (i, rg) in self.ranges.iter().enumerate() {
let last = i + 1 == self.ranges.len();
if rg.start < prev_end || (i > 0 && self.ranges[i - 1].end.is_none()) {
return Err(WireError::Unencodable { what: "range filter", reason: "ranges in order, only the last open-ended" });
}
put_varint(out, rg.start - prev_end);
match rg.end {
Some(e) if e >= rg.start => {
put_varint(out, e - rg.start);
prev_end = e;
}
Some(_) => return Err(WireError::Unencodable { what: "range filter", reason: "a range that ends before it starts" }),
None if last => {}
None => return Err(WireError::Unencodable { what: "range filter", reason: "only the last range is open-ended" }),
}
}
Ok(())
}
fn decode_body(ty: u64, r: &mut Reader<'_>) -> WireResult<Self> {
const W: &str = "range filter";
let set_id = r.u8(W)?;
let property_type = if ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER {
let p = r.varint(W)?;
if p % 2 == 1 {
return Err(WireError::InvalidFilter { reason: "a property filter names an odd property type" });
}
Some(p)
} else {
None
};
let mut ranges = Vec::new();
let mut prev_end = 0u64;
while !r.is_empty() {
let start = prev_end.checked_add(r.varint(W)?).ok_or(WireError::InvalidFilter { reason: "a range start past 2^64-1" })?;
let end = if r.is_empty() {
None
} else {
let e = start.checked_add(r.varint(W)?).ok_or(WireError::InvalidFilter { reason: "a range end past 2^64-1" })?;
prev_end = e;
Some(e)
};
ranges.push(Range { start, end });
}
if ty == param::PRIORITY_FILTER && ranges.iter().any(|rg| rg.start > 255 || rg.end.is_some_and(|e| e > 255)) {
return Err(WireError::InvalidFilter { reason: "a publisher priority above 255" });
}
Ok(RangeFilter { set_id, property_type, ranges })
}
}
/// One Message Parameter, its value typed.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Parameter {
ObjectDeliveryTimeout(u64),
AuthorizationToken(Token),
RendezvousTimeout(u64),
SubgroupDeliveryTimeout(u64),
Expires(u64),
LargestObject(Location),
FillTimeout(u64),
Forward(bool),
SubscriberPriority(u8),
LocationFilter(LocationFilter),
GroupOrder(GroupOrder),
FillParameters(Parameters),
/// A Range Filter: `type` is one of 0x25-0x29; `None` is Length 0, which
/// removes the filter in REQUEST_UPDATE (§3.3.2).
RangeFilter { ty: u64, filter: Option<RangeFilter> },
NewGroupRequest(u64),
TrackNamespacePrefix(TrackNamespace),
IncludeProperties(bool),
}
impl Parameter {
pub fn type_code(&self) -> u64 {
use param::*;
match self {
Parameter::ObjectDeliveryTimeout(_) => OBJECT_DELIVERY_TIMEOUT,
Parameter::AuthorizationToken(_) => AUTHORIZATION_TOKEN,
Parameter::RendezvousTimeout(_) => RENDEZVOUS_TIMEOUT,
Parameter::SubgroupDeliveryTimeout(_) => SUBGROUP_DELIVERY_TIMEOUT,
Parameter::Expires(_) => EXPIRES,
Parameter::LargestObject(_) => LARGEST_OBJECT,
Parameter::FillTimeout(_) => FILL_TIMEOUT,
Parameter::Forward(_) => FORWARD,
Parameter::SubscriberPriority(_) => SUBSCRIBER_PRIORITY,
Parameter::LocationFilter(_) => LOCATION_FILTER,
Parameter::GroupOrder(_) => GROUP_ORDER,
Parameter::FillParameters(_) => FILL_PARAMETERS,
Parameter::RangeFilter { ty, .. } => *ty,
Parameter::NewGroupRequest(_) => NEW_GROUP_REQUEST,
Parameter::TrackNamespacePrefix(_) => TRACK_NAMESPACE_PREFIX,
Parameter::IncludeProperties(_) => INCLUDE_PROPERTIES,
}
}
fn encode_value(&self, out: &mut Vec<u8>) -> WireResult<()> {
match self {
Parameter::ObjectDeliveryTimeout(v)
| Parameter::RendezvousTimeout(v)
| Parameter::SubgroupDeliveryTimeout(v)
| Parameter::Expires(v)
| Parameter::FillTimeout(v)
| Parameter::NewGroupRequest(v) => put_varint(out, *v),
Parameter::AuthorizationToken(t) => {
let b = t.to_bytes();
put_varint(out, b.len() as u64);
out.extend_from_slice(&b);
}
Parameter::LargestObject(l) => l.encode(out),
Parameter::Forward(b) | Parameter::IncludeProperties(b) => out.push(*b as u8),
Parameter::SubscriberPriority(p) => out.push(*p),
Parameter::LocationFilter(f) => f.encode(out)?,
Parameter::GroupOrder(g) => out.push(*g as u8),
Parameter::FillParameters(ps) => {
let mut inner = Vec::new();
ps.encode_seq(&mut inner, Some(&ctx::FILL_PARAMETERS_INNER))?;
put_varint(out, inner.len() as u64);
out.extend_from_slice(&inner);
}
Parameter::RangeFilter { ty, filter } => {
if !param::is_range_filter(*ty) {
return Err(WireError::Unencodable { what: "range filter", reason: "its type is not 0x25 to 0x29" });
}
let mut body = Vec::new();
if let Some(f) = filter {
f.encode_body(*ty, &mut body)?;
}
put_varint(out, body.len() as u64);
out.extend_from_slice(&body);
}
Parameter::TrackNamespacePrefix(ns) => encode_namespace(ns, out)?,
}
Ok(())
}
fn decode_value(ty: u64, r: &mut Reader<'_>) -> WireResult<Self> {
use param::*;
let flag = |r: &mut Reader<'_>, what: &'static str| -> WireResult<bool> {
match r.u8(what)? {
0 => Ok(false),
1 => Ok(true),
v => Err(WireError::InvalidValue { what, value: v as u64 }),
}
};
Ok(match ty {
OBJECT_DELIVERY_TIMEOUT => Parameter::ObjectDeliveryTimeout(r.varint("OBJECT_DELIVERY_TIMEOUT")?),
AUTHORIZATION_TOKEN => Parameter::AuthorizationToken(Token::decode(r.length_prefixed(None, "AUTHORIZATION_TOKEN")?)?),
RENDEZVOUS_TIMEOUT => Parameter::RendezvousTimeout(r.varint("RENDEZVOUS_TIMEOUT")?),
SUBGROUP_DELIVERY_TIMEOUT => Parameter::SubgroupDeliveryTimeout(r.varint("SUBGROUP_DELIVERY_TIMEOUT")?),
EXPIRES => Parameter::Expires(r.varint("EXPIRES")?),
LARGEST_OBJECT => Parameter::LargestObject(Location::decode(r, "LARGEST_OBJECT")?),
FILL_TIMEOUT => Parameter::FillTimeout(r.varint("FILL_TIMEOUT")?),
FORWARD => Parameter::Forward(flag(r, "FORWARD")?),
SUBSCRIBER_PRIORITY => Parameter::SubscriberPriority(r.u8("SUBSCRIBER_PRIORITY")?),
LOCATION_FILTER => Parameter::LocationFilter(LocationFilter::decode(r)?),
GROUP_ORDER => Parameter::GroupOrder(GroupOrder::from_u8(r.u8("GROUP_ORDER")?, "GROUP_ORDER")?),
FILL_PARAMETERS => {
let b = r.length_prefixed(None, "FILL_PARAMETERS")?;
Parameter::FillParameters(Parameters::decode_seq(&mut Reader::new(b), &ctx::FILL_PARAMETERS_INNER)?)
}
SUBGROUP_FILTER..=TRACK_PROPERTY_FILTER => {
let b = r.length_prefixed(None, "range filter")?;
let filter = if b.is_empty() { None } else { Some(RangeFilter::decode_body(ty, &mut Reader::new(b))?) };
Parameter::RangeFilter { ty, filter }
}
NEW_GROUP_REQUEST => Parameter::NewGroupRequest(r.varint("NEW_GROUP_REQUEST")?),
TRACK_NAMESPACE_PREFIX => Parameter::TrackNamespacePrefix(decode_namespace(r)?),
INCLUDE_PROPERTIES => Parameter::IncludeProperties(flag(r, "INCLUDE_PROPERTIES")?),
other => return Err(WireError::UnknownParameter(other)),
})
}
/// The key §3.3.2 forbids repeating, for a Range Filter.
fn filter_key(&self) -> Option<(u64, Option<u8>, Option<u64>)> {
match self {
Parameter::RangeFilter { ty, filter } => Some((*ty, filter.as_ref().map(|f| f.set_id), filter.as_ref().and_then(|f| f.property_type))),
_ => None,
}
}
}
/// A block of Message Parameters, in the order they appear (on the wire,
/// ascending type).
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Parameters(pub Vec<Parameter>);
impl Parameters {
pub fn new() -> Self {
Parameters(Vec::new())
}
pub fn with(mut self, p: Parameter) -> Self {
self.0.push(p);
self
}
pub fn push(&mut self, p: Parameter) -> &mut Self {
self.0.push(p);
self
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// `Number of Parameters` then the parameters (the form in every message).
pub fn encode_counted(&self, out: &mut Vec<u8>, context: Option<&ParamContext>) -> WireResult<()> {
put_varint(out, self.0.len() as u64);
self.encode_seq(out, context)
}
/// The parameters alone, in ascending type order (a stable sort), each type
/// a delta from the previous one. With a context, refused where a parameter
/// is not allowed or repeated where it may not be.
pub fn encode_seq(&self, out: &mut Vec<u8>, context: Option<&ParamContext>) -> WireResult<()> {
if let Some(c) = context {
self.check(c)?;
}
let mut sorted: Vec<&Parameter> = self.0.iter().collect();
sorted.sort_by_key(|p| p.type_code());
let mut prev = 0u64;
for p in sorted {
let t = p.type_code();
put_varint(out, t - prev);
prev = t;
p.encode_value(out)?;
}
Ok(())
}
/// Read `Number of Parameters` then that many parameters, in `context`.
pub fn decode_counted(r: &mut Reader<'_>, context: &ParamContext) -> WireResult<Self> {
let n = r.varint("number of parameters")?;
let mut st = DecodeState::default();
for _ in 0..n {
st.one(r, context)?;
}
Ok(Parameters(st.out))
}
/// Read parameters until `r` is empty (the value of FILL_PARAMETERS).
pub fn decode_seq(r: &mut Reader<'_>, context: &ParamContext) -> WireResult<Self> {
let mut st = DecodeState::default();
while !r.is_empty() {
st.one(r, context)?;
}
Ok(Parameters(st.out))
}
/// The rules a block must keep in `context`, in the order a decoder meets
/// them: each parameter allowed there; a parameter other than a Range Filter
/// or AUTHORIZATION_TOKEN at most once; a Range Filter's (type, SetID,
/// property type) at most once.
pub fn check(&self, context: &ParamContext) -> WireResult<()> {
let mut st = DecodeState::default();
for p in &self.0 {
st.admit(p.clone(), context)?;
}
Ok(())
}
/// Narrow a block read under a union context (REQUEST_UPDATE, REQUEST_OK)
/// to the request it answers or updates.
pub fn check_allowed(&self, context: &ParamContext) -> WireResult<()> {
self.check(context)
}
pub fn get(&self, ty: u64) -> Option<&Parameter> {
self.0.iter().find(|p| p.type_code() == ty)
}
/// The number of Ranges across the block's Range Filters, which
/// MAX_FILTER_RANGES bounds (§9.1.6).
pub fn range_count(&self) -> u64 {
self.0
.iter()
.map(|p| match p {
Parameter::RangeFilter { filter: Some(f), .. } => f.ranges.len() as u64,
_ => 0,
})
.sum()
}
pub fn location_filter(&self) -> Option<LocationFilter> {
self.0.iter().find_map(|p| match p {
Parameter::LocationFilter(f) => Some(*f),
_ => None,
})
}
pub fn largest_object(&self) -> Option<Location> {
self.0.iter().find_map(|p| match p {
Parameter::LargestObject(l) => Some(*l),
_ => None,
})
}
pub fn group_order(&self) -> Option<GroupOrder> {
self.0.iter().find_map(|p| match p {
Parameter::GroupOrder(g) => Some(*g),
_ => None,
})
}
/// FORWARD; 1 (forward) when absent from a message other than an update
/// (§9.20.18).
pub fn forward(&self) -> Option<bool> {
self.0.iter().find_map(|p| match p {
Parameter::Forward(b) => Some(*b),
_ => None,
})
}
/// SUBSCRIBER_PRIORITY; the publisher uses 128 when it is absent (§9.20.7).
pub fn subscriber_priority(&self) -> Option<u8> {
self.0.iter().find_map(|p| match p {
Parameter::SubscriberPriority(v) => Some(*v),
_ => None,
})
}
pub fn expires(&self) -> Option<u64> {
self.0.iter().find_map(|p| match p {
Parameter::Expires(v) => Some(*v),
_ => None,
})
}
pub fn object_delivery_timeout(&self) -> Option<u64> {
self.0.iter().find_map(|p| match p {
Parameter::ObjectDeliveryTimeout(v) => Some(*v),
_ => None,
})
}
pub fn range_filters(&self) -> impl Iterator<Item = (u64, Option<&RangeFilter>)> {
self.0.iter().filter_map(|p| match p {
Parameter::RangeFilter { ty, filter } => Some((*ty, filter.as_ref())),
_ => None,
})
}
}
#[derive(Default)]
struct DecodeState {
out: Vec<Parameter>,
prev: u64,
}
impl DecodeState {
fn one(&mut self, r: &mut Reader<'_>, context: &ParamContext) -> WireResult<()> {
let delta = r.varint("parameter type")?;
let ty = self.prev.checked_add(delta).ok_or(WireError::TypeOverflow { what: "message parameters" })?;
self.prev = ty;
if param::name(ty).is_none() {
return Err(WireError::UnknownParameter(ty));
}
if !context.allowed.contains(&ty) {
return Err(WireError::ParameterNotAllowed { param: ty, context: context.name });
}
let p = Parameter::decode_value(ty, r)?;
self.admit_checked(p)
}
fn admit(&mut self, p: Parameter, context: &ParamContext) -> WireResult<()> {
let ty = p.type_code();
if !context.allowed.contains(&ty) {
return Err(WireError::ParameterNotAllowed { param: ty, context: context.name });
}
self.admit_checked(p)
}
fn admit_checked(&mut self, p: Parameter) -> WireResult<()> {
let ty = p.type_code();
if let Some(key) = p.filter_key() {
if self.out.iter().any(|q| q.filter_key() == Some(key)) {
return Err(WireError::DuplicateFilter { param: ty, set_id: key.1.unwrap_or(0), property: key.2 });
}
} else if ty != param::AUTHORIZATION_TOKEN && self.out.iter().any(|q| q.type_code() == ty) {
return Err(WireError::Duplicate { what: "message parameter", ty });
}
self.out.push(p);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn round(ps: &Parameters, c: &ParamContext) -> Parameters {
let mut b = Vec::new();
ps.encode_counted(&mut b, Some(c)).unwrap();
let mut r = Reader::new(&b);
let back = Parameters::decode_counted(&mut r, c).unwrap();
assert!(r.is_empty());
back
}
#[test]
fn subscribe_parameters_round_trip_in_type_order() {
let ps = Parameters::new()
.with(Parameter::LocationFilter(LocationFilter::NextObject))
.with(Parameter::Forward(true))
.with(Parameter::RangeFilter {
ty: param::OBJECT_PROPERTY_FILTER,
filter: Some(RangeFilter { set_id: 0, property_type: Some(0x3a40), ranges: vec![Range { start: 0, end: Some(2) }] }),
})
.with(Parameter::RangeFilter { ty: param::SUBGROUP_FILTER, filter: Some(RangeFilter { set_id: 1, property_type: None, ranges: vec![Range { start: 3, end: Some(5) }, Range { start: 10, end: None }] }) })
.with(Parameter::FillParameters(Parameters::new().with(Parameter::LocationFilter(LocationFilter::RelativeStart { start_group: 1 }))));
let back = round(&ps, &ctx::SUBSCRIBE);
let mut sorted = ps.0.clone();
sorted.sort_by_key(|p| p.type_code());
assert_eq!(back.0, sorted);
}
#[test]
fn the_drafts_range_example() {
// §8.6: ranges 3-5 and 10-15 encode as Start=3, End=2, Start=5, End=5.
let f = RangeFilter { set_id: 0, property_type: None, ranges: vec![Range { start: 3, end: Some(5) }, Range { start: 10, end: Some(15) }] };
let mut b = Vec::new();
f.encode_body(param::OBJECTID_FILTER, &mut b).unwrap();
assert_eq!(b, vec![0, 3, 2, 5, 5]);
}
#[test]
fn refusals() {
// A parameter not allowed in SUBSCRIBE_OK.
let ps = Parameters::new().with(Parameter::Forward(true));
assert!(matches!(ps.check(&ctx::SUBSCRIBE_OK), Err(WireError::ParameterNotAllowed { .. })));
// FORWARD twice.
let b = [2u8, param::FORWARD as u8, 1, 0, 1];
assert_eq!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::Duplicate { what: "message parameter", ty: param::FORWARD }));
// A filter repeated with the same SetID.
let b = [2u8, 0x26, 2, 0, 1, 0, 2, 0, 4];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::DuplicateFilter { .. })));
// An unknown parameter.
let b = [1u8, 0x05, 0];
assert_eq!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::UnknownParameter(5)));
// FORWARD = 2.
let b = [1u8, 0x10, 2];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidValue { .. })));
// A Location Filter Type of 6.
let b = [1u8, 0x21, 6];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidValue { .. })));
// FILL_PARAMETERS holding FORWARD.
let b = [1u8, 0x23, 2, 0x10, 1];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::ParameterNotAllowed { .. })));
// A priority filter above 255.
let b = [1u8, 0x27, 4, 0, 0x81, 0x00, 0];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidFilter { .. })));
// A count far beyond the bytes: refused as truncated, without allocating it.
let b = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::Truncated { .. })));
}
#[test]
fn relative_start_clamps() {
assert_eq!(LocationFilter::relative_start(0, Some(9)), 10);
assert_eq!(LocationFilter::relative_start(1, Some(9)), 9);
assert_eq!(LocationFilter::relative_start(50, Some(9)), 0);
assert_eq!(LocationFilter::relative_start(0, Some(u64::MAX)), u64::MAX);
assert_eq!(LocationFilter::relative_start(0, None), 0);
}
}

View file

@ -0,0 +1,415 @@
//! Request IDs, request streams and their flow control (§6.3, §6.4.2, §9.1.7,
//! §9.5).
//!
//! In -22 every request is a bidirectional stream of its own, so the number of
//! requests in flight is bounded by the transport's stream limits (QUIC's
//! MAX_STREAMS); the draft keeps no request-level credit of its own. What it
//! does bound is REQUEST_UPDATE: MAX_REQUEST_UPDATES (§9.1.7) caps the updates
//! outstanding on one request stream. This module holds:
//!
//! - [`RequestIds`], an endpoint's own Request IDs: even from 0 for a client,
//! odd from 1 for a server, each 2 above the last (§6.4.2.1);
//! - [`PeerRequestIds`], which refuses a peer's Request ID of the wrong parity
//! or seen before (INVALID_REQUEST_ID);
//! - [`ControlStreamState`], the order on a control stream: SETUP, then GOAWAY
//! at most once (§6.3, §9.2);
//! - [`RequestStream`], the messages each side of one request stream may send
//! and when (§6.3, §6.4.2, §3.6, §4.2, §9), with MAX_REQUEST_UPDATES credit.
use std::collections::{BTreeSet, HashSet};
use super::codes::SessionError;
use super::control::ControlMessage;
use super::error::{WireError, WireResult};
use crate::track::TrackNamespace;
/// Which end of the transport session an endpoint is (§1.3).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Perspective {
Client,
Server,
}
impl Perspective {
pub fn peer(self) -> Perspective {
match self {
Perspective::Client => Perspective::Server,
Perspective::Server => Perspective::Client,
}
}
/// The low bit of this endpoint's Request IDs.
pub fn request_id_parity(self) -> u64 {
match self {
Perspective::Client => 0,
Perspective::Server => 1,
}
}
}
/// The seven requests that open a request stream (§6.3).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum RequestKind {
Subscribe,
Publish,
Fetch,
TrackStatus,
PublishNamespace,
SubscribeNamespace,
SubscribeTracks,
}
/// An endpoint's own Request IDs.
#[derive(Clone, Debug)]
pub struct RequestIds {
next: Option<u64>,
}
impl RequestIds {
pub fn new(me: Perspective) -> Self {
RequestIds { next: Some(me.request_id_parity()) }
}
/// The next Request ID, or `None` once the 64-bit space is spent.
pub fn next(&mut self) -> Option<u64> {
let id = self.next?;
self.next = id.checked_add(2);
Some(id)
}
}
/// The peer's Request IDs seen so far.
#[derive(Clone, Debug)]
pub struct PeerRequestIds {
parity: u64,
/// Every ID of the peer's parity below this has been seen.
floor: u64,
/// IDs seen at or above `floor`.
above: BTreeSet<u64>,
window: usize,
}
/// How many of the peer's Request IDs may arrive ahead of the lowest one not
/// yet seen before this implementation refuses to track more.
pub const PEER_REQUEST_ID_WINDOW: usize = 4096;
impl PeerRequestIds {
/// The IDs `peer` sends.
pub fn new(peer: Perspective) -> Self {
PeerRequestIds { parity: peer.request_id_parity(), floor: peer.request_id_parity(), above: BTreeSet::new(), window: PEER_REQUEST_ID_WINDOW }
}
/// Admit one Request ID: refused with INVALID_REQUEST_ID when its low bit is
/// not the peer's or it was seen before (§6.4.2.1).
pub fn accept(&mut self, id: u64) -> WireResult<()> {
if id % 2 != self.parity {
return Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, reason: "a Request ID of the other endpoint's parity" });
}
if id < self.floor || self.above.contains(&id) {
return Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, reason: "a Request ID seen before" });
}
if self.above.len() >= self.window {
return Err(WireError::LocalLimit { what: "peer Request IDs ahead of the lowest unseen", len: self.above.len() as u64 + 1, limit: self.window as u64 });
}
self.above.insert(id);
while self.above.remove(&self.floor) {
match self.floor.checked_add(2) {
Some(f) => self.floor = f,
None => break,
}
}
Ok(())
}
}
/// The order of messages on a control stream (§6.3, §9.2).
#[derive(Clone, Debug, Default)]
pub struct ControlStreamState {
setup_seen: bool,
goaway_seen: bool,
}
impl ControlStreamState {
pub fn new() -> Self {
Self::default()
}
/// Admit the next message received on the peer's control stream.
pub fn on_message(&mut self, m: &ControlMessage) -> WireResult<()> {
match (self.setup_seen, m) {
(false, ControlMessage::Setup(_)) => {
self.setup_seen = true;
Ok(())
}
(false, _) => Err(WireError::UnexpectedMessage("a control stream that does not begin with SETUP")),
(true, ControlMessage::Goaway(_)) if !self.goaway_seen => {
self.goaway_seen = true;
Ok(())
}
(true, ControlMessage::Goaway(_)) => Err(WireError::UnexpectedMessage("a second GOAWAY on the control stream")),
(true, _) => Err(WireError::UnexpectedMessage("a message other than GOAWAY on the control stream after SETUP")),
}
}
}
/// Which side of a request stream an endpoint is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
/// It sent the request.
Requester,
/// It received the request.
Responder,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
/// No response yet.
AwaitingResponse,
/// Accepted: the stream carries what the request type allows.
Accepted,
/// Refused, or the request is done: nothing more on that direction.
Done,
}
/// One request stream, as one endpoint sees it: the messages each side may
/// send and in what order, the updates outstanding each way against
/// MAX_REQUEST_UPDATES, and the namespaces a SUBSCRIBE_NAMESPACE has announced.
#[derive(Clone, Debug)]
pub struct RequestStream {
kind: RequestKind,
me: Side,
/// The responder's direction: its first message is the response.
phase: Phase,
/// The publisher of a subscription has sent PUBLISH_DONE.
publish_done: bool,
/// REQUEST_UPDATEs sent by the requester side and by the responder side
/// (only PUBLISH has both) that have no answer yet.
updates_from_requester: u64,
updates_from_responder: u64,
/// MAX_REQUEST_UPDATES this endpoint advertised (bounds what it receives) and
/// the peer advertised (bounds what it sends); 0 is no limit.
my_limit: u64,
peer_limit: u64,
goaway_received: bool,
namespaces: HashSet<TrackNamespace>,
}
impl RequestStream {
/// The stream of a request, as `me` sees it. `my_limit` and `peer_limit` are
/// the two endpoints' MAX_REQUEST_UPDATES.
pub fn new(kind: RequestKind, me: Side, my_limit: u64, peer_limit: u64) -> Self {
RequestStream {
kind,
me,
phase: Phase::AwaitingResponse,
publish_done: false,
updates_from_requester: 0,
updates_from_responder: 0,
my_limit,
peer_limit,
goaway_received: false,
namespaces: HashSet::new(),
}
}
/// A stream the peer opened with `first`, which must be a request (§6.3:
/// PROTOCOL_VIOLATION otherwise).
pub fn opened_by_peer(first: &ControlMessage, my_limit: u64, peer_limit: u64) -> WireResult<Self> {
let kind = first.opens_request().ok_or(WireError::UnexpectedMessage("a request stream that does not begin with a request"))?;
Ok(RequestStream::new(kind, Side::Responder, my_limit, peer_limit))
}
pub fn kind(&self) -> RequestKind {
self.kind
}
/// Whether the request was accepted (its first response was not REQUEST_ERROR).
pub fn accepted(&self) -> bool {
self.phase == Phase::Accepted
}
/// The publisher of the subscription this stream carries.
fn publisher(&self) -> Option<Side> {
match self.kind {
RequestKind::Subscribe => Some(Side::Responder),
RequestKind::Publish => Some(Side::Requester),
_ => None,
}
}
/// Check a message this endpoint is about to send; refused where the peer
/// would refuse it.
pub fn on_send(&mut self, m: &ControlMessage) -> WireResult<()> {
let me = self.me;
self.admit(me, m, self.peer_limit)
}
/// Admit a message received from the peer.
pub fn on_receive(&mut self, m: &ControlMessage) -> WireResult<()> {
let from = match self.me {
Side::Requester => Side::Responder,
Side::Responder => Side::Requester,
};
if let ControlMessage::Goaway(_) = m {
if self.goaway_received {
return Err(WireError::UnexpectedMessage("a second GOAWAY on one request stream"));
}
self.goaway_received = true;
return Ok(());
}
self.admit(from, m, self.my_limit)
}
fn admit(&mut self, from: Side, m: &ControlMessage, limit: u64) -> WireResult<()> {
use ControlMessage as M;
if let M::Goaway(_) = m {
return Ok(());
}
if m.opens_request().is_some() {
return Err(WireError::UnexpectedMessage("a request inside another request's stream"));
}
if matches!(m, M::Setup(_)) {
return Err(WireError::UnexpectedMessage("SETUP on a request stream"));
}
let publisher = self.publisher();
match m {
M::RequestUpdate(_) => {
let allowed = self.kind != RequestKind::TrackStatus
&& (from == Side::Requester || self.kind == RequestKind::Publish);
if !allowed {
return Err(WireError::UnexpectedMessage("REQUEST_UPDATE from an endpoint that may not update this request"));
}
if from == Side::Responder && self.phase != Phase::Accepted {
return Err(WireError::UnexpectedMessage("a subscriber's REQUEST_UPDATE before its PUBLISH_OK"));
}
let n = if from == Side::Requester { &mut self.updates_from_requester } else { &mut self.updates_from_responder };
if limit != 0 && *n >= limit {
return Err(WireError::Session { code: SessionError::TOO_MANY_REQUEST_UPDATES, reason: "a REQUEST_UPDATE past MAX_REQUEST_UPDATES" });
}
*n += 1;
return Ok(());
}
M::RequestOk(_) | M::RequestError(_) | M::SubscribeOk(_) | M::FetchOk(_) if from == Side::Responder && self.phase == Phase::AwaitingResponse => {
let ok = match (self.kind, m) {
(_, M::RequestError(_)) => false,
(RequestKind::Subscribe, M::SubscribeOk(_)) | (RequestKind::Fetch, M::FetchOk(_)) => true,
(RequestKind::Subscribe | RequestKind::Fetch, _) => {
return Err(WireError::UnexpectedMessage("the first response is not SUBSCRIBE_OK, FETCH_OK or REQUEST_ERROR"))
}
(_, M::RequestOk(_)) => true,
_ => return Err(WireError::UnexpectedMessage("the first response is not REQUEST_OK or REQUEST_ERROR")),
};
self.phase = if ok && self.kind != RequestKind::TrackStatus { Phase::Accepted } else { Phase::Done };
return Ok(());
}
_ => {}
}
if from == Side::Responder && self.phase == Phase::AwaitingResponse {
// §3.6, §4.1, §4.2: the response is the first message the responder sends.
// PUBLISH's subscriber is its responder; nothing it sends may come first.
return Err(WireError::UnexpectedMessage("a message before the response on a request stream"));
}
if from == Side::Responder && self.phase == Phase::Done {
return Err(WireError::UnexpectedMessage("a message after the request ended"));
}
match m {
M::RequestOk(_) | M::RequestError(_) => {
// An answer to an update the other side sent.
let n = if from == Side::Responder { &mut self.updates_from_requester } else { &mut self.updates_from_responder };
if *n == 0 {
return Err(WireError::UnexpectedMessage("REQUEST_OK or REQUEST_ERROR with no update outstanding"));
}
*n -= 1;
Ok(())
}
M::PublishDone(_) | M::PublishStateNotify(_) => {
if publisher != Some(from) {
return Err(WireError::UnexpectedMessage("PUBLISH_DONE or PUBLISH_STATE_NOTIFY from an endpoint that is not the subscription's publisher"));
}
if self.publish_done {
return Err(WireError::UnexpectedMessage("a message after PUBLISH_DONE"));
}
if matches!(m, M::PublishDone(_)) {
self.publish_done = true;
}
Ok(())
}
M::Namespace(ns) | M::NamespaceDone(ns) => {
if self.kind != RequestKind::SubscribeNamespace || from != Side::Responder {
return Err(WireError::UnexpectedMessage("NAMESPACE or NAMESPACE_DONE outside a SUBSCRIBE_NAMESPACE response"));
}
if matches!(m, M::Namespace(_)) {
self.namespaces.insert(ns.clone());
} else if !self.namespaces.remove(ns) {
return Err(WireError::UnexpectedMessage("NAMESPACE_DONE before its NAMESPACE"));
}
Ok(())
}
M::PublishSkipped(_) => {
if self.kind != RequestKind::SubscribeTracks || from != Side::Responder {
return Err(WireError::UnexpectedMessage("PUBLISH_SKIPPED outside a SUBSCRIBE_TRACKS response"));
}
Ok(())
}
M::SubscribeOk(_) | M::FetchOk(_) => Err(WireError::UnexpectedMessage("a second SUBSCRIBE_OK or FETCH_OK")),
_ => Err(WireError::UnexpectedMessage("a message this request stream does not carry")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::draft22::control::{NamespaceRequest, RequestOk, RequestUpdate, SubscribeOk};
use crate::draft22::kvp::Properties;
use crate::draft22::params::Parameters;
#[test]
fn request_ids() {
let mut c = RequestIds::new(Perspective::Client);
let mut s = RequestIds::new(Perspective::Server);
assert_eq!((c.next(), c.next(), s.next(), s.next()), (Some(0), Some(2), Some(1), Some(3)));
let mut peer = PeerRequestIds::new(Perspective::Client);
peer.accept(2).unwrap();
peer.accept(0).unwrap();
assert!(matches!(peer.accept(1), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
assert!(matches!(peer.accept(0), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
assert!(matches!(peer.accept(2), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
peer.accept(4).unwrap();
let mut top = RequestIds { next: Some(u64::MAX - 1) };
assert_eq!((top.next(), top.next()), (Some(u64::MAX - 1), None));
}
fn ok() -> ControlMessage {
ControlMessage::RequestOk(RequestOk { params: Parameters::new(), track_properties: Properties::new() })
}
fn upd() -> ControlMessage {
ControlMessage::RequestUpdate(RequestUpdate { request_id: 2, params: Parameters::new() })
}
#[test]
fn a_subscription_stream() {
// The subscriber's view: it may update once at a time (the peer's limit is 1).
let mut st = RequestStream::new(RequestKind::Subscribe, Side::Requester, 0, 1);
st.on_send(&upd()).unwrap();
assert!(matches!(st.on_send(&upd()), Err(WireError::Session { code: SessionError::TOO_MANY_REQUEST_UPDATES, .. })));
assert!(st.on_receive(&ok()).is_err(), "REQUEST_OK cannot be the first response to SUBSCRIBE");
st.on_receive(&ControlMessage::SubscribeOk(SubscribeOk { track_alias: 1, params: Parameters::new(), track_properties: Properties::new() })).unwrap();
st.on_receive(&ok()).unwrap();
st.on_send(&upd()).unwrap();
assert!(st.on_receive(&ControlMessage::Namespace(TrackNamespace::from_strs(["a"]))).is_err());
}
#[test]
fn a_namespace_subscription_stream() {
let first = ControlMessage::SubscribeNamespace(NamespaceRequest { request_id: 1, prefix: TrackNamespace::from_strs(["a"]), params: Parameters::new() });
let mut st = RequestStream::opened_by_peer(&first, 0, 0).unwrap();
let ns = TrackNamespace::from_strs(["b"]);
assert!(st.on_send(&ControlMessage::Namespace(ns.clone())).is_err(), "NAMESPACE before the response");
st.on_send(&ok()).unwrap();
assert!(st.on_send(&ControlMessage::NamespaceDone(ns.clone())).is_err(), "NAMESPACE_DONE before NAMESPACE");
st.on_send(&ControlMessage::Namespace(ns.clone())).unwrap();
st.on_send(&ControlMessage::NamespaceDone(ns)).unwrap();
assert!(RequestStream::opened_by_peer(&ok(), 0, 0).is_err());
}
}

View file

@ -0,0 +1,116 @@
//! The Authorization Token structure (§8.9), carried by the AUTHORIZATION TOKEN
//! Setup Option (§9.1.4) and Message Parameter (§9.20.2).
//!
//! The codec reads the structure; the alias cache it implies (registration,
//! MAX_AUTH_TOKEN_CACHE_SIZE, retirement) is the session's.
use super::error::{WireError, WireResult};
use super::io::{put_varint, Reader};
/// Alias Types (§8.9, Table 12).
pub mod alias {
pub const DELETE: u64 = 0x0;
pub const REGISTER: u64 = 0x1;
pub const USE_ALIAS: u64 = 0x2;
pub const USE_VALUE: u64 = 0x3;
}
/// One Token structure.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Token {
/// Retire `alias` and its value.
Delete { alias: u64 },
/// Register `alias` for `token_type` and `value`.
Register { alias: u64, token_type: u64, value: Vec<u8> },
/// Use the value registered under `alias`.
UseAlias { alias: u64 },
/// Use `value` as given.
UseValue { token_type: u64, value: Vec<u8> },
}
impl Token {
pub fn alias_type(&self) -> u64 {
match self {
Token::Delete { .. } => alias::DELETE,
Token::Register { .. } => alias::REGISTER,
Token::UseAlias { .. } => alias::USE_ALIAS,
Token::UseValue { .. } => alias::USE_VALUE,
}
}
/// The structure's bytes (the value of the option or parameter carrying it).
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
put_varint(&mut out, self.alias_type());
match self {
Token::Delete { alias } | Token::UseAlias { alias } => put_varint(&mut out, *alias),
Token::Register { alias, token_type, value } => {
put_varint(&mut out, *alias);
put_varint(&mut out, *token_type);
out.extend_from_slice(value);
}
Token::UseValue { token_type, value } => {
put_varint(&mut out, *token_type);
out.extend_from_slice(value);
}
}
out
}
/// Read a whole byte string as one Token. The Token Value runs to the end of
/// the enclosing field. Anything that does not read is
/// [`WireError::MalformedToken`] (§8.9: KEY_VALUE_FORMATTING_ERROR in -22).
pub fn decode(b: &[u8]) -> WireResult<Token> {
let bad = |reason| WireError::MalformedToken { reason };
let mut r = Reader::new(b);
let t = r.varint("token alias type").map_err(|_| bad("truncated"))?;
let v = |r: &mut Reader<'_>| r.varint("token field").map_err(|_| bad("truncated"));
let tok = match t {
alias::DELETE => Token::Delete { alias: v(&mut r)? },
alias::REGISTER => {
let alias = v(&mut r)?;
let token_type = v(&mut r)?;
Token::Register { alias, token_type, value: r.rest().to_vec() }
}
alias::USE_ALIAS => Token::UseAlias { alias: v(&mut r)? },
alias::USE_VALUE => {
let token_type = v(&mut r)?;
Token::UseValue { token_type, value: r.rest().to_vec() }
}
_ => return Err(bad("unknown alias type")),
};
if !r.is_empty() {
return Err(bad("bytes after an alias"));
}
Ok(tok)
}
/// The token type and value it states itself (`None` for an alias
/// reference), for §8.9's uniqueness rule within a message.
pub fn explicit(&self) -> Option<(u64, &[u8])> {
match self {
Token::Register { token_type, value, .. } | Token::UseValue { token_type, value } => Some((*token_type, value)),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_alias_type_round_trips() {
for t in [
Token::Delete { alias: 9 },
Token::Register { alias: 1, token_type: 0, value: b"jwt".to_vec() },
Token::UseAlias { alias: 300 },
Token::UseValue { token_type: 0x9d, value: vec![] },
] {
assert_eq!(Token::decode(&t.to_bytes()).unwrap(), t);
}
assert!(matches!(Token::decode(&[0x04]), Err(WireError::MalformedToken { .. })));
assert!(matches!(Token::decode(&[0x00, 0x01, 0x02]), Err(WireError::MalformedToken { .. })));
assert!(matches!(Token::decode(&[]), Err(WireError::MalformedToken { .. })));
}
}

View file

@ -0,0 +1,161 @@
//! The variable-length integer of `draft-ietf-moq-transport-22` §8.1.
//!
//! The number of leading 1 bits of the first byte gives the length of the
//! encoding, from 1 byte (`0xxxxxxx`, 7 usable bits) to 9 bytes (`11111111`
//! followed by the 64-bit value). Every 64-bit value is representable, and an
//! encoding need not be the shortest: `0x25`, `0x8025` and `0xc00025` all read
//! as 37. This is not QUIC's variable-length integer (RFC 9000 §16), which the
//! drafts before -17 used.
use super::error::WireError;
/// The largest value the encoding carries: every `u64`.
pub const VARINT_MAX: u64 = u64::MAX;
/// The number of bytes the shortest encoding of `v` takes.
pub const fn encoded_len(v: u64) -> usize {
// Usable bits for lengths 1..=8 are 7 * len; the 9-byte form carries 64.
let mut len = 1;
while len < 9 {
if len == 8 {
// 8 bytes: 56 usable bits.
if v < (1u64 << 56) {
return 8;
}
return 9;
}
if v < (1u64 << (7 * len)) {
return len;
}
len += 1;
}
9
}
/// Append the shortest encoding of `v` to `out`.
pub fn put(out: &mut Vec<u8>, v: u64) {
put_with_len(out, v, encoded_len(v)).expect("the shortest length always fits");
}
/// Append `v` encoded in exactly `len` bytes (1 to 9), which may be longer than
/// the shortest form (§8.1: "any encoding length that can represent the value
/// is valid"). Refused when `v` does not fit in `len` bytes.
pub fn put_with_len(out: &mut Vec<u8>, v: u64, len: usize) -> Result<(), WireError> {
if !(1..=9).contains(&len) || len < encoded_len(v) {
return Err(WireError::Unencodable { what: "varint length", reason: "the value does not fit the requested length" });
}
if len == 9 {
out.push(0xff);
out.extend_from_slice(&v.to_be_bytes());
return Ok(());
}
// `len - 1` leading ones, then a zero, then the value's top bits.
let prefix: u8 = if len == 1 { 0 } else { !(0xffu8 >> (len - 1)) };
let bytes = v.to_be_bytes();
let tail = &bytes[8 - len..];
out.push(prefix | tail[0]);
out.extend_from_slice(&tail[1..]);
Ok(())
}
/// The length an encoding takes, read from its first byte.
pub const fn len_from_first(first: u8) -> usize {
let ones = first.leading_ones() as usize;
if ones >= 8 {
9
} else {
ones + 1
}
}
/// Decode one varint from the front of `buf`: `(value, bytes read)`, or
/// [`WireError::Truncated`] when `buf` ends inside it.
pub fn get(buf: &[u8], what: &'static str) -> Result<(u64, usize), WireError> {
let first = *buf.first().ok_or(WireError::Truncated { what })?;
let len = len_from_first(first);
if buf.len() < len {
return Err(WireError::Truncated { what });
}
if len == 9 {
let mut b = [0u8; 8];
b.copy_from_slice(&buf[1..9]);
return Ok((u64::from_be_bytes(b), 9));
}
// `len` leading bits are the prefix (with its terminating zero); an
// 8-byte form has no value bits in its first byte. Shift in u32 so len 8
// cannot overflow the shift.
let mask: u8 = if len == 1 { 0x7f } else { (0xffu32 >> len) as u8 };
let mut v = (first & mask) as u64;
for &b in &buf[1..len] {
v = (v << 8) | b as u64;
}
Ok((v, len))
}
/// Encode `v` as a fresh byte vector (shortest form).
pub fn to_vec(v: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(9);
put(&mut out, v);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
/// Table 4 of the draft, both ways where the example is the shortest form.
#[test]
fn the_drafts_examples() {
let table: [(&str, u64, bool); 8] = [
("25", 37, true),
("8025", 37, false),
("bbbd", 15_293, true),
("ed7f3e7d", 226_442_877, true),
("faa1a0e403d8", 2_893_212_287_960, true),
("fc8998abc66bc0", 151_288_809_941_952, true),
("fefa318fa8e3ca11", 70_423_237_261_249_041, true),
("ffffffffffffffffff", u64::MAX, true),
];
for (h, v, shortest) in table {
let b = hex(h);
assert_eq!(get(&b, "t").unwrap(), (v, b.len()), "{h}");
if shortest {
assert_eq!(to_vec(v), b, "{h}");
}
let mut long = Vec::new();
put_with_len(&mut long, v, b.len()).unwrap();
assert_eq!(long, b, "{h} at its own length");
}
}
#[test]
fn boundaries_round_trip_at_every_length_that_fits() {
let mut vals = vec![0u64, u64::MAX];
for bits in [7u32, 14, 21, 28, 35, 42, 49, 56, 63] {
vals.push((1u64 << bits) - 1);
vals.push(1u64 << bits);
}
for v in vals {
for len in encoded_len(v)..=9 {
let mut b = Vec::new();
put_with_len(&mut b, v, len).unwrap();
assert_eq!(b.len(), len);
assert_eq!(get(&b, "t").unwrap(), (v, len), "{v} in {len}");
}
if encoded_len(v) > 1 {
assert!(put_with_len(&mut Vec::new(), v, encoded_len(v) - 1).is_err());
}
}
}
#[test]
fn a_short_buffer_is_truncated() {
assert_eq!(get(&[], "x"), Err(WireError::Truncated { what: "x" }));
assert_eq!(get(&[0xc0, 0x00], "x"), Err(WireError::Truncated { what: "x" }));
assert_eq!(get(&[0xff; 8], "x"), Err(WireError::Truncated { what: "x" }));
}
}

View file

@ -56,6 +56,9 @@ pub enum MoqError {
#[error("jwp: {0}")]
Jwp(String),
#[error("moqt wire: {0}")]
Wire(#[from] crate::draft22::WireError),
}
impl From<jwp::JwpError> for MoqError {

View file

@ -1,41 +1,62 @@
//! **joule-moq** — MoQT (Media over QUIC Transport) wire protocol + JWP receipt
//! **joule-moq** — MoqT (Media over QUIC Transport) wire protocol + JWP receipt
//! extension for the CommunicationOS broadcast surface.
//!
//! MoQT is the IETF-track pub/sub transport for live and broadcast media over
//! QUIC. Tracks fan out through relays to subscribers; objects within a track
//! are independently addressable, cacheable, and replayable. This crate
//! delivers the wire protocol *plus* a first-class JWP receipt extension so
//! every object carries verifiable provenance, joule cost, and (optionally)
//! MLS-wrapped end-to-end ciphertext.
//! MoqT is the IETF-track pub/sub transport for live and broadcast media over
//! QUIC and WebTransport. Tracks fan out through relays to subscribers; objects
//! within a track are independently addressable, cacheable, and replayable.
//! This crate delivers the wire protocol *plus* a first-class JWP receipt
//! extension so every object carries verifiable provenance, joule cost, and
//! (optionally) MLS-wrapped end-to-end ciphertext.
//!
//! ## The draft it implements
//!
//! `draft-ietf-moq-transport-22` (1 October 2026), negotiated by the ALPN
//! `moqt-22` ([`draft::Draft`]). [`MOQT_DRAFT_VERSION`] is its conventional
//! code, `0xff000016`. Drafts do not interoperate with each other, so a session
//! speaks one from its first byte; the codec of -22 is a module of its own
//! ([`draft22`]), and another draft would be a sibling of it.
//!
//! ## What this crate ships
//!
//! - Wire codec for the MoQT control + data planes ([`wire`], [`messages`])
//! - The -22 wire codec ([`draft22`]): the variable-length integer, Key-Value
//! Pairs, Properties and Setup Options, the Authorization Token, every
//! control message and its parameters, Request IDs and request-stream rules,
//! subgroup streams, datagrams, fetch streams and padding, the status and
//! error codes; bounded decoders with a typed error for every refusal.
//! Re-exported for the control plane by [`messages`] and [`wire`].
//! - The carriage of this crate's objects and receipts on -22 objects
//! ([`carriage`]): object properties, the `WAI_RECEIPT` property of WAI
//! `extensions/moq-streaming-format.md` §5, and receipts tracks (§4.1 there).
//! - Track / Group / Object identifiers and metadata ([`track`])
//! - Session state machine for publisher / subscriber / relay roles ([`session`])
//! - Endpoint trait surface ([`endpoint`])
//! - **JWP receipt extension** — batched per-group signing with Merkle inclusion
//! proofs per object, identity-bound publication, joule attribution ([`receipt`])
//! - In-memory mock for testing without QUIC ([`mock`]), whose relay also forwards
//! staged tracks on a caller-driven clock: per-hop delivery timeouts, capping by
//! - In-memory mock for testing without QUIC ([`mock`]), whose objects, receipts
//! and requests pass through the -22 codec; its relay also forwards staged
//! tracks on a caller-driven clock: per-hop delivery timeouts, capping by
//! subscription, and the receipts of dropped objects forwarded all the same
//! - Staged tracks, one per layer of a WAI stage set ([`staged`]), and receipt chains
//! kept per publisher key and track ([`ReceiptChains`])
//! - A native QUIC transport behind the `quic` feature (`transport_quic`): -22
//! control streams, request streams, subgroup and fetch streams and datagrams
//! over quinn.
//!
//! ## What this crate intentionally does not ship (yet)
//!
//! - A production QUIC implementation (gated behind the `quic` feature; v0.1
//! exposes the trait surface only).
//! - Browser-native subscriber (will be a separate WASM crate).
//! - WebTransport (MOQT over HTTP/3), a relay with priority fan-out, and a
//! browser client: the next slice builds them on this codec.
//! - The authorization token cache (§8.9: aliases, MAX_AUTH_TOKEN_CACHE_SIZE)
//! and the subscription filters' evaluation at a publisher: the codec reads
//! and writes them, and the session does not yet act on them.
//! - The relay binary (`joule-moq-relay`) and publisher SDK (`joule-moq-publisher`)
//! live in their own crates and depend on this one.
//!
//! ## Standards alignment
//!
//! Targets `draft-ietf-moq-transport` (working group draft, evolving). Wire
//! constants and message types are versioned per the draft revision implemented
//! by [`MOQT_DRAFT_VERSION`]. The JWP receipt extension is a non-normative
//! addition we plan to submit as `draft-charlot-moq-jwp-extensions`.
//! Implements `draft-ietf-moq-transport-22`, a working-group draft that is
//! still changing. The JWP receipt extension is a non-normative addition we plan
//! to submit as `draft-charlot-moq-jwp-extensions`.
//!
//! ## Receipt-first design
//!
@ -43,6 +64,9 @@
//! by construction. There is no "raw" object path that bypasses receipt
//! generation.
pub mod carriage;
pub mod draft;
pub mod draft22;
pub mod endpoint;
pub mod error;
pub mod messages;
@ -57,13 +81,14 @@ pub mod wire;
#[cfg(feature = "quic")]
pub mod transport_quic;
pub use carriage::{ReceiptBundle, WaiReceiptProperty};
pub use draft::Draft;
pub use endpoint::{
MoqEndpoint, Publisher, Relay, Subscriber, SubscriptionHandle,
};
pub use error::{MoqError, MoqResult};
pub use messages::{
AnnounceMessage, ControlMessage, ObjectHeader, SetupMessage,
SubscribeMessage, SubscribeOk,
ControlMessage, NamespaceRequest, ObjectHeader, Setup, SubscribeOk, TrackRequest,
};
pub use receipt::{
compose_group_class, Admission, Anchoring, BoundRefused, ChainEvent, ChainRefusal, GroupReceipt, HoldBound, ObjectReceipt,
@ -81,15 +106,16 @@ pub use wai_dispatch::{
};
pub use wire::{decode_varint, encode_varint, WireCodec};
/// MoQT draft revision targeted by this crate.
/// MoqT draft revision targeted by this crate: `draft-ietf-moq-transport-22`,
/// as the conventional code `0xff000000 + 22`.
///
/// The IETF MoQ working group is iterating; bumping this constant signals
/// a wire-incompatible version of the protocol.
pub const MOQT_DRAFT_VERSION: u32 = 0xff00_0008; // matches draft-08-style version
/// Bumping this constant signals a wire-incompatible version of the protocol.
/// -22 carries no version in SETUP; the ALPN `moqt-22` selects it.
pub const MOQT_DRAFT_VERSION: u32 = draft22::VERSION;
/// JWP receipt extension version.
///
/// Independent of the MoQT draft version. Bumped when the receipt header
/// Independent of the MoqT draft version. Bumped when the receipt header
/// schema changes. 2: the labelled object leaf and the labelled group payload
/// (optional members; a receipt without them keeps its bytes).
pub const JWP_MOQ_EXTENSION_VERSION: u8 = 2;

View file

@ -1,120 +1,27 @@
//! MoQT control-plane messages and object headers.
//! MoqT control messages and the crate's object header.
//!
//! These types follow `draft-ietf-moq-transport`. Type tags and field
//! ordering will track the IETF draft; bumping [`crate::MOQT_DRAFT_VERSION`]
//! signals a wire-incompatible change.
//! The control plane is `draft-ietf-moq-transport-22`'s (§9), re-exported from
//! [`crate::draft22`]: SETUP on each endpoint's unidirectional control stream,
//! every request on a bidirectional stream of its own, answered there. Bumping
//! [`crate::MOQT_DRAFT_VERSION`] signals a wire-incompatible change; a draft is
//! chosen per session by [`crate::draft::Draft`].
//!
//! The control plane runs on a single bidirectional QUIC stream (the
//! "control stream"); object delivery uses unidirectional QUIC streams,
//! one per group (or per object, depending on forwarding preference).
//! [`ObjectHeader`] is the crate's draft-neutral view of one delivered object;
//! [`crate::carriage`] maps it onto a -22 subgroup object.
use crate::error::{MoqError, MoqResult};
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias, TrackName, TrackNamespace};
use crate::wire::{decode_bytes, decode_varint, encode_bytes, encode_varint};
use bytes::{Bytes, BytesMut};
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias};
use serde::{Deserialize, Serialize};
// ── Control-plane message-type tags (from draft) ──
// Track the latest draft; these are placeholder mappings consistent with
// the structure of the published spec.
pub const MSG_CLIENT_SETUP: u64 = 0x40;
pub const MSG_SERVER_SETUP: u64 = 0x41;
pub const MSG_ANNOUNCE: u64 = 0x06;
pub const MSG_ANNOUNCE_OK: u64 = 0x07;
pub const MSG_ANNOUNCE_ERROR: u64 = 0x08;
pub const MSG_UNANNOUNCE: u64 = 0x09;
pub const MSG_SUBSCRIBE: u64 = 0x03;
pub const MSG_SUBSCRIBE_OK: u64 = 0x04;
pub const MSG_SUBSCRIBE_ERROR: u64 = 0x05;
pub const MSG_UNSUBSCRIBE: u64 = 0x0a;
pub const MSG_GOAWAY: u64 = 0x10;
/// Top-level control-plane message envelope.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ControlMessage {
Setup(SetupMessage),
Announce(AnnounceMessage),
AnnounceOk { namespace: TrackNamespace },
AnnounceError { namespace: TrackNamespace, code: u64, reason: String },
Unannounce { namespace: TrackNamespace },
Subscribe(SubscribeMessage),
SubscribeOk(SubscribeOk),
SubscribeError { subscribe_id: u64, code: u64, reason: String },
Unsubscribe { subscribe_id: u64 },
Goaway { uri: Option<String> },
}
/// SETUP — exchanged at session establishment to negotiate version and roles.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetupMessage {
/// MoQT draft version offered (CLIENT_SETUP) or selected (SERVER_SETUP).
pub versions: Vec<u32>,
/// Role bits: 0x01 = publisher, 0x02 = subscriber, 0x03 = pubsub.
pub role: u8,
/// Whether the peer requests JWP receipt extension support.
pub jwp_receipts: bool,
/// Optional human-readable peer identity hint (production binds via
/// TrustOS-issued credentials, not this field).
pub peer_label: Option<String>,
}
/// ANNOUNCE — publisher tells the relay (or peer) that it has tracks
/// available under the given namespace.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnnounceMessage {
pub namespace: TrackNamespace,
/// Optional bound publisher credential (TrustOS DID / VC envelope).
/// Empty in the bare protocol; the JWP-MoQ extension populates this.
pub publisher_credential: Vec<u8>,
}
/// SUBSCRIBE — request delivery of a track.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubscribeMessage {
pub subscribe_id: u64,
pub track_alias: TrackAlias,
pub namespace: TrackNamespace,
pub track_name: TrackName,
/// Filter for which objects to deliver: latest group, range, etc.
pub filter: SubscribeFilter,
/// Whether the subscriber requires receipt verification end-to-end.
pub require_receipts: bool,
/// Matryoshka fidelity cap. If `Some(n)`, the relay forwards only
/// objects whose [`ObjectHeader::mrl_layer`] is `<= n`. `None` means
/// the subscriber accepts every layer the publisher emits. Always
/// `None` for non-MRL tracks; older subscribers that don't include
/// this field on the wire are decoded as `None`.
///
/// **Superseded for tracks of packaging `wai`** (WAI staged-delivery §13,
/// moq-streaming-format §4.1): a WAI stage set carries each layer on a track of
/// its own, and a subscriber caps its stage by the layer tracks it subscribes to; a
/// relay reads no envelope and no header to cap layers. The field keeps its wire
/// form for the tracks that still use it.
#[serde(default)]
pub max_mrl_layer: Option<u8>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum SubscribeFilter {
LatestGroup,
LatestObject,
AbsoluteStart { group: GroupId, object: ObjectId },
AbsoluteRange { start: (GroupId, ObjectId), end: (GroupId, ObjectId) },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubscribeOk {
pub subscribe_id: u64,
/// Server expiry hint, milliseconds from now (0 = no expiry).
pub expires_ms: u64,
/// The largest (group, object) currently published, when known.
pub largest: Option<(GroupId, ObjectId)>,
}
pub use crate::draft22::control::{
msg, ControlMessage, Fetch, FetchOk, Goaway, NamespaceRequest, Publish, PublishDone, PublishSkipped, Redirect, RequestError, RequestOk,
RequestUpdate, Setup, Subscribe, SubscribeOk, TrackRequest, TrackStatus,
};
pub use crate::draft22::params::{GroupOrder, LocationFilter, Parameter, Parameters};
/// Header preceding each delivered object on a data stream.
///
/// In the JWP-MoQ extension, an additional [`ObjectReceipt`] header is
/// appended after this struct (see [`crate::receipt`]).
/// On the wire it is a -22 subgroup object ([`crate::carriage`]); the object's
/// [`crate::receipt::ObjectReceipt`] rides in its properties.
///
/// **MRL (Matryoshka Representation Learning) extension.** When a track
/// carries layered codec output — Encodec residual codebooks, layered
@ -122,9 +29,11 @@ pub struct SubscribeOk {
/// group corresponds to one "doll layer". The [`ObjectHeader::mrl_layer`]
/// field indicates the layer index (0 = coarsest base, monotonically
/// increasing fidelity); [`ObjectHeader::mrl_total_layers`] is the total
/// number of layers the publisher emitted in this group. Subscribers can
/// declare a `max_mrl_layer` policy and the relay drops higher layers
/// without disturbing the per-object signed receipts.
/// number of layers the publisher emitted in this group. A subscriber caps
/// the layers it receives with an OBJECT_PROPERTY_FILTER on the `MRL_LAYER`
/// property ([`mrl_cap_filter`]; -22 §3.3.2, which needs the peer's
/// MAX_FILTER_RANGES above 0), and the relay drops higher layers without
/// disturbing the per-object signed receipts.
///
/// For tracks that do not carry MRL-structured content, both fields
/// default to `0`. Existing receipts and wire-format consumers are
@ -211,374 +120,75 @@ mod object_header_wai_tests {
}
}
// ── Wire encoding ──
impl ControlMessage {
pub fn encode(&self) -> MoqResult<Bytes> {
let mut buf = BytesMut::new();
let (tag, body) = self.encode_body()?;
encode_varint(tag, &mut buf)?;
encode_varint(body.len() as u64, &mut buf)?;
buf.extend_from_slice(&body);
Ok(buf.freeze())
}
fn encode_body(&self) -> MoqResult<(u64, Bytes)> {
let mut body = BytesMut::new();
let tag = match self {
ControlMessage::Setup(s) => {
encode_varint(s.versions.len() as u64, &mut body)?;
for v in &s.versions {
encode_varint(*v as u64, &mut body)?;
}
body.extend_from_slice(&[s.role]);
body.extend_from_slice(&[s.jwp_receipts as u8]);
let label = s.peer_label.as_deref().unwrap_or("");
encode_bytes(label.as_bytes(), &mut body)?;
MSG_CLIENT_SETUP
}
ControlMessage::Announce(a) => {
encode_namespace(&a.namespace, &mut body)?;
encode_bytes(&a.publisher_credential, &mut body)?;
MSG_ANNOUNCE
}
ControlMessage::AnnounceOk { namespace } => {
encode_namespace(namespace, &mut body)?;
MSG_ANNOUNCE_OK
}
ControlMessage::AnnounceError { namespace, code, reason } => {
encode_namespace(namespace, &mut body)?;
encode_varint(*code, &mut body)?;
encode_bytes(reason.as_bytes(), &mut body)?;
MSG_ANNOUNCE_ERROR
}
ControlMessage::Unannounce { namespace } => {
encode_namespace(namespace, &mut body)?;
MSG_UNANNOUNCE
}
ControlMessage::Subscribe(s) => {
encode_varint(s.subscribe_id, &mut body)?;
encode_varint(s.track_alias.0, &mut body)?;
encode_namespace(&s.namespace, &mut body)?;
encode_bytes(&s.track_name.0, &mut body)?;
encode_filter(&s.filter, &mut body)?;
body.extend_from_slice(&[s.require_receipts as u8]);
// Trailing optional MRL cap: [present:u8][value:u8 if present].
// Older receivers ignore unread trailing bytes; newer
// receivers default to None when these bytes are absent.
match s.max_mrl_layer {
Some(cap) => body.extend_from_slice(&[1u8, cap]),
None => body.extend_from_slice(&[0u8]),
}
MSG_SUBSCRIBE
}
ControlMessage::SubscribeOk(ok) => {
encode_varint(ok.subscribe_id, &mut body)?;
encode_varint(ok.expires_ms, &mut body)?;
match ok.largest {
Some((g, o)) => {
body.extend_from_slice(&[1u8]);
encode_varint(g.0, &mut body)?;
encode_varint(o.0, &mut body)?;
}
None => body.extend_from_slice(&[0u8]),
}
MSG_SUBSCRIBE_OK
}
ControlMessage::SubscribeError { subscribe_id, code, reason } => {
encode_varint(*subscribe_id, &mut body)?;
encode_varint(*code, &mut body)?;
encode_bytes(reason.as_bytes(), &mut body)?;
MSG_SUBSCRIBE_ERROR
}
ControlMessage::Unsubscribe { subscribe_id } => {
encode_varint(*subscribe_id, &mut body)?;
MSG_UNSUBSCRIBE
}
ControlMessage::Goaway { uri } => {
let s = uri.as_deref().unwrap_or("");
encode_bytes(s.as_bytes(), &mut body)?;
MSG_GOAWAY
}
};
Ok((tag, body.freeze()))
}
pub fn decode(src: &mut Bytes) -> MoqResult<Self> {
let tag = decode_varint(src)?;
let len = decode_varint(src)? as usize;
if src.len() < len {
return Err(MoqError::WireDecode(format!(
"control: need {} bytes, have {}",
len,
src.len()
)));
}
let mut body = src.split_to(len);
match tag {
MSG_CLIENT_SETUP | MSG_SERVER_SETUP => {
let n = decode_varint(&mut body)? as usize;
let mut versions = Vec::with_capacity(n);
for _ in 0..n {
versions.push(decode_varint(&mut body)? as u32);
}
let role = body.split_to(1)[0];
let jwp_receipts = body.split_to(1)[0] != 0;
let label_bytes = decode_bytes(&mut body)?;
let peer_label = if label_bytes.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&label_bytes).into_owned())
};
Ok(ControlMessage::Setup(SetupMessage {
versions,
role,
jwp_receipts,
peer_label,
}))
}
MSG_ANNOUNCE => {
let namespace = decode_namespace(&mut body)?;
let publisher_credential = decode_bytes(&mut body)?.to_vec();
Ok(ControlMessage::Announce(AnnounceMessage {
namespace,
publisher_credential,
}))
}
MSG_ANNOUNCE_OK => {
let namespace = decode_namespace(&mut body)?;
Ok(ControlMessage::AnnounceOk { namespace })
}
MSG_SUBSCRIBE => {
let subscribe_id = decode_varint(&mut body)?;
let track_alias = TrackAlias(decode_varint(&mut body)?);
let namespace = decode_namespace(&mut body)?;
let track_name = TrackName(decode_bytes(&mut body)?.to_vec());
let filter = decode_filter(&mut body)?;
let require_receipts = body.split_to(1)[0] != 0;
// Optional trailing MRL cap. Older subscribers omit these
// bytes; treat absence as None.
let max_mrl_layer = if body.is_empty() {
None
} else {
let present = body.split_to(1)[0] != 0;
if present {
if body.is_empty() {
return Err(MoqError::WireDecode(
"max_mrl_layer flagged present but value byte missing".into(),
));
}
Some(body.split_to(1)[0])
} else {
None
}
};
Ok(ControlMessage::Subscribe(SubscribeMessage {
subscribe_id,
track_alias,
namespace,
track_name,
filter,
require_receipts,
max_mrl_layer,
}))
}
MSG_SUBSCRIBE_OK => {
let subscribe_id = decode_varint(&mut body)?;
let expires_ms = decode_varint(&mut body)?;
let has_largest = body.split_to(1)[0] != 0;
let largest = if has_largest {
Some((
GroupId(decode_varint(&mut body)?),
ObjectId(decode_varint(&mut body)?),
))
} else {
None
};
Ok(ControlMessage::SubscribeOk(SubscribeOk {
subscribe_id,
expires_ms,
largest,
}))
}
MSG_UNSUBSCRIBE => {
let subscribe_id = decode_varint(&mut body)?;
Ok(ControlMessage::Unsubscribe { subscribe_id })
}
other => Err(MoqError::UnexpectedMessage(other)),
}
}
}
fn encode_namespace(ns: &TrackNamespace, dst: &mut BytesMut) -> MoqResult<()> {
encode_varint(ns.0.len() as u64, dst)?;
for seg in &ns.0 {
encode_bytes(seg, dst)?;
}
Ok(())
}
fn decode_namespace(src: &mut Bytes) -> MoqResult<TrackNamespace> {
let n = decode_varint(src)? as usize;
let mut segs = Vec::with_capacity(n);
for _ in 0..n {
segs.push(decode_bytes(src)?.to_vec());
}
Ok(TrackNamespace(segs))
}
fn encode_filter(f: &SubscribeFilter, dst: &mut BytesMut) -> MoqResult<()> {
match f {
SubscribeFilter::LatestGroup => {
dst.extend_from_slice(&[0u8]);
}
SubscribeFilter::LatestObject => {
dst.extend_from_slice(&[1u8]);
}
SubscribeFilter::AbsoluteStart { group, object } => {
dst.extend_from_slice(&[2u8]);
encode_varint(group.0, dst)?;
encode_varint(object.0, dst)?;
}
SubscribeFilter::AbsoluteRange { start, end } => {
dst.extend_from_slice(&[3u8]);
encode_varint(start.0.0, dst)?;
encode_varint(start.1.0, dst)?;
encode_varint(end.0.0, dst)?;
encode_varint(end.1.0, dst)?;
}
}
Ok(())
}
fn decode_filter(src: &mut Bytes) -> MoqResult<SubscribeFilter> {
let tag = src.split_to(1)[0];
match tag {
0 => Ok(SubscribeFilter::LatestGroup),
1 => Ok(SubscribeFilter::LatestObject),
2 => Ok(SubscribeFilter::AbsoluteStart {
group: GroupId(decode_varint(src)?),
object: ObjectId(decode_varint(src)?),
/// A SUBSCRIBE parameter that caps a layered track at `cap`: an
/// OBJECT_PROPERTY_FILTER (-22 §9.20.13) passing objects whose `MRL_LAYER`
/// property ([`crate::carriage::prop::MRL_LAYER`]) is 0 to `cap`. This is the
/// -22 form of the `max_mrl_layer` field that SUBSCRIBE carried before. An
/// object without the property does not pass a property filter, so a publisher
/// of a capped track states the layer on every object.
pub fn mrl_cap_filter(cap: u8) -> Parameter {
Parameter::RangeFilter {
ty: crate::draft22::params::param::OBJECT_PROPERTY_FILTER,
filter: Some(crate::draft22::params::RangeFilter {
set_id: 0,
property_type: Some(crate::carriage::prop::MRL_LAYER),
ranges: vec![crate::draft22::params::Range { start: 0, end: Some(cap as u64) }],
}),
3 => Ok(SubscribeFilter::AbsoluteRange {
start: (GroupId(decode_varint(src)?), ObjectId(decode_varint(src)?)),
end: (GroupId(decode_varint(src)?), ObjectId(decode_varint(src)?)),
}),
other => Err(MoqError::WireDecode(format!("filter tag {}", other))),
}
}
/// The layer cap a SUBSCRIBE's parameters set by [`mrl_cap_filter`], if any.
pub fn mrl_cap(params: &Parameters) -> Option<u8> {
params.range_filters().find_map(|(ty, f)| {
let f = f?;
let ok = ty == crate::draft22::params::param::OBJECT_PROPERTY_FILTER
&& f.property_type == Some(crate::carriage::prop::MRL_LAYER)
&& f.ranges.len() == 1
&& f.ranges[0].start == 0;
if ok {
f.ranges[0].end.and_then(|e| u8::try_from(e).ok())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
mod d22_tests {
use super::*;
use crate::track::{TrackName, TrackNamespace};
/// The draft-08-style tests of SUBSCRIBE, rewritten for -22: a SUBSCRIBE
/// round-trips, and a layer cap rides it as a property filter.
#[test]
fn subscribe_roundtrip() {
let msg = ControlMessage::Subscribe(SubscribeMessage {
subscribe_id: 7,
track_alias: TrackAlias(42),
namespace: TrackNamespace::from_strs(["tv", "transaction.science"]),
track_name: TrackName::from_str("live"),
filter: SubscribeFilter::LatestGroup,
require_receipts: true,
max_mrl_layer: None,
});
let mut bytes = msg.encode().unwrap();
let decoded = ControlMessage::decode(&mut bytes).unwrap();
match decoded {
ControlMessage::Subscribe(s) => {
assert_eq!(s.subscribe_id, 7);
assert_eq!(s.track_alias.0, 42);
assert!(s.require_receipts);
assert_eq!(s.max_mrl_layer, None);
fn subscribe_round_trips_with_and_without_a_layer_cap() {
for cap in [None, Some(2u8)] {
let mut params = Parameters::new().with(Parameter::LocationFilter(LocationFilter::NextObject));
if let Some(c) = cap {
params.push(mrl_cap_filter(c));
}
let m = ControlMessage::Subscribe(TrackRequest {
request_id: 6,
namespace: TrackNamespace::from_strs(["tv", "transaction.science"]),
name: TrackName::from_str("live"),
params,
});
let back = ControlMessage::decode_exact(&m.encode().unwrap()).unwrap();
assert_eq!(back, m);
match back {
ControlMessage::Subscribe(s) => assert_eq!(mrl_cap(&s.params), cap),
_ => panic!("wrong variant"),
}
_ => panic!("wrong variant"),
}
}
#[test]
fn subscribe_roundtrip_with_mrl_cap() {
let msg = ControlMessage::Subscribe(SubscribeMessage {
subscribe_id: 99,
track_alias: TrackAlias(7),
namespace: TrackNamespace::from_strs(["calls", "demo"]),
track_name: TrackName::from_str("audio"),
filter: SubscribeFilter::LatestGroup,
require_receipts: true,
max_mrl_layer: Some(2),
fn publish_namespace_round_trips() {
let m = ControlMessage::PublishNamespace(NamespaceRequest {
request_id: 1,
prefix: TrackNamespace::from_strs(["tv", "transaction.science", "ticker"]),
params: Parameters::new(),
});
let mut bytes = msg.encode().unwrap();
let decoded = ControlMessage::decode(&mut bytes).unwrap();
match decoded {
ControlMessage::Subscribe(s) => {
assert_eq!(s.max_mrl_layer, Some(2));
}
_ => panic!("wrong variant"),
}
}
/// An older encoder that omits the trailing MRL bytes should still
/// decode (max_mrl_layer is `None`). We simulate by trimming the
/// last byte (the `0u8` "no-cap" sentinel) before decode.
#[test]
fn subscribe_decode_handles_legacy_wire_without_mrl_bytes() {
let msg = ControlMessage::Subscribe(SubscribeMessage {
subscribe_id: 1,
track_alias: TrackAlias(0),
namespace: TrackNamespace::from_strs(["x"]),
track_name: TrackName::from_str("y"),
filter: SubscribeFilter::LatestGroup,
require_receipts: false,
max_mrl_layer: None,
});
// Encode normally, then strip the trailing byte (the "no-cap"
// sentinel) to simulate a legacy peer that didn't include it.
let full = msg.encode().unwrap();
let mut buf = bytes::BytesMut::from(&full[..]);
// Decrement the inner length varint by one byte and drop the last
// byte. Easier: just rebuild with a hand-trimmed payload that
// decodes the same: re-decode requires knowing the framing — so
// verify legacy compatibility via the final-byte-empty path.
// Drop the trailing 0u8 sentinel and adjust the length.
let len_decrement = 1usize;
let mut hand = bytes::BytesMut::new();
// The encoded message is: [tag varint][len varint][body bytes...].
// We re-emit it with body shorter by 1 and len reduced by 1.
let mut src = full.clone();
let tag = decode_varint(&mut src).unwrap();
let inner_len = decode_varint(&mut src).unwrap() as usize;
let body = src.split_to(inner_len);
let new_body = body.slice(..body.len() - len_decrement);
encode_varint(tag, &mut hand).unwrap();
encode_varint(new_body.len() as u64, &mut hand).unwrap();
hand.extend_from_slice(&new_body);
let mut hand_bytes = hand.freeze();
let decoded = ControlMessage::decode(&mut hand_bytes).unwrap();
match decoded {
ControlMessage::Subscribe(s) => {
assert_eq!(s.max_mrl_layer, None);
}
_ => panic!("wrong variant"),
}
// sanity: ensure we used buf (silence dead-code warning).
let _ = buf.split();
}
#[test]
fn announce_roundtrip() {
let ns = TrackNamespace::from_strs(["tv", "transaction.science", "ticker"]);
let msg = ControlMessage::Announce(AnnounceMessage {
namespace: ns.clone(),
publisher_credential: vec![1, 2, 3, 4],
});
let mut bytes = msg.encode().unwrap();
let decoded = ControlMessage::decode(&mut bytes).unwrap();
match decoded {
ControlMessage::Announce(a) => {
assert_eq!(a.namespace, ns);
assert_eq!(a.publisher_credential, vec![1, 2, 3, 4]);
}
_ => panic!("wrong variant"),
}
assert_eq!(ControlMessage::decode_exact(&m.encode().unwrap()).unwrap(), m);
}
}

View file

@ -5,7 +5,13 @@
//! transport is a `tokio::sync::mpsc` channel; both ends share a process.
//!
//! Receipts produced here are *real* — Ed25519-signed group roots, BLAKE3
//! Merkle trees, end-to-end verifiable. Only the transport is mocked.
//! Merkle trees, end-to-end verifiable. Only the transport is mocked: what
//! crosses it is `draft-ietf-moq-transport-22`. Each object is encoded as a
//! subgroup stream and decoded on the far side ([`crate::carriage`]), its
//! receipt riding in its properties; a receipts-track bundle is encoded as the
//! one object of its group; SUBSCRIBE, SUBSCRIBE_OK, PUBLISH_NAMESPACE and
//! REQUEST_OK are encoded and decoded with Request IDs from the right end's
//! space.
//!
//! [`MockRelay`] also forwards staged tracks (WAI `extensions/moq-streaming-format.md`
//! §4.1) on a clock its caller drives: a delivery timeout per track or per object,
@ -16,6 +22,10 @@
//! for receipts whatever became of the objects they cover. [`RelayStats`] counts what it
//! forwarded and dropped. Nothing sleeps: time is what the caller says it is.
use crate::carriage::{decode_object_stream, encode_object_stream, ObjectExtras, WaiReceiptProperty};
pub use crate::carriage::ReceiptBundle;
use crate::draft22::{ControlMessage, NamespaceRequest, Parameters, Perspective, RequestIds, RequestOk, SubscribeOk, TrackRequest};
use crate::draft22::kvp::Properties;
use crate::endpoint::{
DeliveredObject, GroupHandle, MoqEndpoint, Publisher, Relay, Subscriber, SubscriptionHandle,
};
@ -56,6 +66,19 @@ impl MockFabric {
TrackAlias(self.next_alias.fetch_add(1, Ordering::SeqCst))
}
/// Carry one object across the mock wire: as a -22 subgroup stream of one
/// object, decoded on the far side.
fn deliver_wire(&self, key: &TrackKey, wire: &[u8]) -> MoqResult<()> {
let (header, payload, extras) = decode_object_stream(wire)?;
if let (Some(w), Some(r)) = (&extras.wai_receipt, &extras.receipt)
&& (w.content_hash != r.content_hash || w.joules_micro != r.joules_micro)
{
return Err(MoqError::ReceiptVerification("the WAI_RECEIPT property disagrees with the object receipt".into()));
}
self.deliver(key, DeliveredObject { header, payload, receipt: extras.receipt });
Ok(())
}
fn deliver(&self, key: &TrackKey, obj: DeliveredObject) {
let mut routes = self.routes.lock().unwrap();
if let Some(senders) = routes.get_mut(key) {
@ -84,6 +107,8 @@ pub struct MockPublisher {
signing_key: SigningKey,
announced: Mutex<Vec<TrackNamespace>>,
track_aliases: Mutex<HashMap<TrackKey, TrackAlias>>,
/// The publisher is the server end of its mock session: odd Request IDs.
request_ids: Mutex<RequestIds>,
}
impl MockPublisher {
@ -99,6 +124,7 @@ impl MockPublisher {
signing_key,
announced: Mutex::new(Vec::new()),
track_aliases: Mutex::new(HashMap::new()),
request_ids: Mutex::new(RequestIds::new(Perspective::Server)),
}
}
@ -126,7 +152,13 @@ impl MoqEndpoint for MockPublisher {
#[async_trait]
impl Publisher for MockPublisher {
async fn announce(&mut self, namespace: TrackNamespace) -> MoqResult<()> {
self.announced.lock().unwrap().push(namespace);
let request_id = self.request_ids.lock().unwrap().next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))?;
let sent = ControlMessage::PublishNamespace(NamespaceRequest { request_id, prefix: namespace, params: Parameters::new() });
let ControlMessage::PublishNamespace(got) = across(&sent)? else {
return Err(MoqError::WireDecode("PUBLISH_NAMESPACE crossed the wire as another message".into()));
};
across(&ControlMessage::RequestOk(RequestOk { params: Parameters::new(), track_properties: Properties::new() }))?;
self.announced.lock().unwrap().push(got.prefix);
Ok(())
}
@ -227,14 +259,9 @@ impl GroupHandle for MockGroupHandle {
merkle_proof: proof,
energy_class: None,
};
fabric.deliver(
&key,
DeliveredObject {
header,
payload,
receipt: Some(receipt),
},
);
let wai_receipt = WaiReceiptProperty::from_receipts(&receipt, &group_receipt);
let extras = ObjectExtras { receipt: Some(receipt), wai_receipt, delivery_timeout_ms: None };
fabric.deliver_wire(&key, &encode_object_stream(&header, &payload, &extras)?)?;
}
Ok(group_receipt)
}
@ -244,18 +271,24 @@ impl GroupHandle for MockGroupHandle {
pub struct MockSubscriber {
pub fabric: Arc<MockFabric>,
next_subscribe_id: AtomicU64,
/// The subscriber is the client end of its mock session: even Request IDs.
request_ids: Mutex<RequestIds>,
}
impl MockSubscriber {
pub fn new(fabric: Arc<MockFabric>) -> Self {
Self {
fabric,
next_subscribe_id: AtomicU64::new(0),
request_ids: Mutex::new(RequestIds::new(Perspective::Client)),
}
}
}
/// One control message across the mock wire: encoded, then decoded.
fn across(m: &ControlMessage) -> MoqResult<ControlMessage> {
Ok(ControlMessage::decode_exact(&m.encode()?)?)
}
#[async_trait]
impl MoqEndpoint for MockSubscriber {
fn version(&self) -> Option<u32> {
@ -281,17 +314,26 @@ impl Subscriber for MockSubscriber {
_require_receipts: bool,
) -> MoqResult<SubscriptionHandle> {
let (tx, rx) = mpsc::channel(64);
let track_alias = self.fabric.allocate_alias();
let request_id = self.request_ids.lock().unwrap().next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))?;
let sent = ControlMessage::Subscribe(TrackRequest { request_id, namespace, name: track_name, params: Parameters::new() });
let ControlMessage::Subscribe(got) = across(&sent)? else {
return Err(MoqError::WireDecode("SUBSCRIBE crossed the wire as another message".into()));
};
// The fabric, as the publisher, picks the Track Alias and answers.
let answer = ControlMessage::SubscribeOk(SubscribeOk { track_alias: self.fabric.allocate_alias().0, params: Parameters::new(), track_properties: Properties::new() });
let ControlMessage::SubscribeOk(ok) = across(&answer)? else {
return Err(MoqError::WireDecode("SUBSCRIBE_OK crossed the wire as another message".into()));
};
self.fabric.register(
TrackKey {
namespace,
track_name,
namespace: got.namespace,
track_name: got.name,
},
tx,
);
Ok(SubscriptionHandle {
subscribe_id: self.next_subscribe_id.fetch_add(1, Ordering::SeqCst),
track_alias,
subscribe_id: got.request_id,
track_alias: TrackAlias(ok.track_alias),
rx,
})
}
@ -339,14 +381,6 @@ pub struct RelayStats {
pub receipts_forwarded_for_dropped: u64,
}
/// One group's receipts, as one object of a receipts track carries them: the group
/// receipt and the object receipts of one group of the receipts track's media track.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceiptBundle {
pub group: GroupReceipt,
pub objects: Vec<ObjectReceipt>,
}
/// Something the relay sent, and when, on the caller's clock.
#[derive(Clone, Debug)]
pub struct Sent<T> {
@ -483,6 +517,13 @@ impl MockRelay {
object_timeout_ms: Option<u64>,
) -> MoqResult<()> {
let key = TrackKey { namespace: namespace.clone(), track_name: track_name.clone() };
// The object crosses the upstream hop as a -22 subgroup stream; its own
// delivery timeout rides as its OBJECT_DELIVERY_TIMEOUT property (§10.2),
// which the relay reads back.
let extras = ObjectExtras { receipt: object.receipt.clone(), wai_receipt: None, delivery_timeout_ms: object_timeout_ms };
let (header, payload, extras) = decode_object_stream(&encode_object_stream(&object.header, &object.payload, &extras)?)?;
let object = DeliveredObject { header, payload, receipt: extras.receipt };
let object_timeout_ms = extras.delivery_timeout_ms;
let send_ms = received_ms
.checked_add(queue_ms)
.ok_or_else(|| MoqError::InvalidSessionState(format!("a send time past the clock ({received_ms} + {queue_ms})")))?;
@ -505,6 +546,15 @@ impl MockRelay {
/// nothing in it to forward it: it goes at once to every subscriber of that receipts
/// track, whatever became of the objects it covers.
pub fn ingest_receipts(&self, namespace: &TrackNamespace, receipts_track: &TrackName, bundle: ReceiptBundle, received_ms: u64) {
// The bundle crosses the upstream hop as the one object of its group on the
// receipts track. A bundle the codec cannot carry is not forwarded.
let bundle = match bundle.encode_stream(bundle.group.track_alias.0, bundle.group.group_id.0, 0).and_then(|b| ReceiptBundle::decode_stream(&b)) {
Ok((_, b)) => b,
Err(e) => {
tracing::warn!("receipt bundle not forwarded: {e}");
return;
}
};
let rkey = TrackKey { namespace: namespace.clone(), track_name: receipts_track.clone() };
let mut st = self.staged.lock().unwrap();
// For counting only: the media track whose receipts track this is, and the keys of

View file

@ -296,7 +296,7 @@ enum ClassEvidence {
}
/// The structural checks a class's signed encoding must pass beside `figure`.
fn check_class(c: &[u8], figure: u64) -> Result<(), String> {
pub(crate) fn check_class(c: &[u8], figure: u64) -> Result<(), String> {
let (tag, u, ev) = read_class(c).ok_or("unreadable class")?;
match tag {
0 if figure == 0 => return Ok(()),

View file

@ -1,13 +1,23 @@
//! Session state machine for a MoQT endpoint.
//! Session state machine for a MoqT endpoint.
//!
//! A session is a single QUIC connection between two MoQT peers. Both ends
//! traverse the same lifecycle: `Init → Handshaking → Established → Closing
//! → Closed`. The role (publisher / subscriber / pubsub / relay) is
//! negotiated during handshake.
//! A session is a single QUIC connection (or WebTransport session) between two
//! MoqT peers. Both ends traverse the same lifecycle: `Init → Handshaking →
//! Established → Closing → Closed`.
//!
//! Under `draft-ietf-moq-transport-22` the draft is fixed before the first MOQT
//! byte, by ALPN or WebTransport protocol ([`MoqSession::select_alpn`]); each
//! endpoint then opens a control stream with a SETUP of its own, carrying Setup
//! Options and no version and no role. Extensions are declared there: this crate
//! declares the JWP receipt extension by
//! [`crate::carriage::SETUP_OPTION_JWP_RECEIPTS`]. The role
//! ([`SessionRole`]) is this endpoint's own business; -22 does not exchange it.
use crate::carriage::SETUP_OPTION_JWP_RECEIPTS;
use crate::draft::Draft;
use crate::draft22::kvp::{opt, KeyValuePair, SetupOptions};
use crate::draft22::{PeerRequestIds, Perspective, RequestIds, Setup, WireError};
use crate::error::{MoqError, MoqResult};
use crate::messages::SetupMessage;
use crate::MOQT_DRAFT_VERSION;
use crate::JWP_MOQ_EXTENSION_VERSION;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -20,6 +30,8 @@ pub enum SessionRole {
}
impl SessionRole {
/// The role as the bits draft-08's SETUP carried. -22 exchanges no role;
/// kept for callers that log or store it.
pub fn role_bits(&self) -> u8 {
match self {
SessionRole::Publisher => 0x01,
@ -39,20 +51,36 @@ pub enum SessionState {
Closed,
}
/// Cross-cutting state for a single MoQT session.
/// Cross-cutting state for a single MoqT session.
pub struct MoqSession {
pub role: SessionRole,
/// Whether this endpoint opened the transport session.
pub perspective: Perspective,
pub state: SessionState,
/// The draft the ALPN selected.
pub draft: Option<Draft>,
/// That draft's 32-bit code, once the session is established.
pub negotiated_version: Option<u32>,
pub jwp_receipts_enabled: bool,
/// Active subscriptions, keyed by subscribe_id.
/// Active subscriptions, keyed by the Request ID of their SUBSCRIBE or PUBLISH.
pub subscriptions: HashMap<u64, SubscriptionState>,
/// Track alias → subscribe_id reverse index.
/// Track alias → Request ID reverse index.
pub alias_index: HashMap<u64, u64>,
/// This endpoint's own Request IDs.
pub request_ids: RequestIds,
/// The peer's Request IDs seen so far.
pub peer_request_ids: PeerRequestIds,
/// The peer's Setup Options, once its SETUP arrived.
pub peer_options: Option<SetupOptions>,
/// MAX_REQUEST_UPDATES this endpoint declares (0: no limit).
pub max_request_updates: u64,
/// MAX_FILTER_RANGES this endpoint declares (0: the peer sends no Range Filter).
pub max_filter_ranges: u64,
}
#[derive(Clone, Debug)]
pub struct SubscriptionState {
/// The Request ID of the SUBSCRIBE (or PUBLISH) that made it.
pub subscribe_id: u64,
pub track_alias: u64,
pub require_receipts: bool,
@ -61,65 +89,106 @@ pub struct SubscriptionState {
pub joules_consumed_micro: u64,
}
/// The MOQT_IMPLEMENTATION this crate sends (§9.1.5).
pub fn implementation_name() -> String {
format!("joule-moq/{}", crate::VERSION)
}
impl MoqSession {
/// A session as the server end of its transport session.
pub fn new(role: SessionRole) -> Self {
Self::with_perspective(role, Perspective::Server)
}
pub fn with_perspective(role: SessionRole, perspective: Perspective) -> Self {
Self {
role,
perspective,
state: SessionState::Init,
draft: None,
negotiated_version: None,
jwp_receipts_enabled: false,
subscriptions: HashMap::new(),
alias_index: HashMap::new(),
request_ids: RequestIds::new(perspective),
peer_request_ids: PeerRequestIds::new(perspective.peer()),
peer_options: None,
max_request_updates: 0,
max_filter_ranges: 0,
}
}
/// Produce the SETUP message this endpoint will send first.
pub fn build_setup(&self, request_jwp: bool, label: Option<String>) -> SetupMessage {
SetupMessage {
versions: vec![MOQT_DRAFT_VERSION],
role: self.role.role_bits(),
jwp_receipts: request_jwp,
peer_label: label,
}
/// Choose the draft from the ALPNs a client offered (a server), or check the
/// one a server selected (a client): refused when this crate speaks none of
/// them.
pub fn select_alpn<'a>(&mut self, offered: impl IntoIterator<Item = &'a [u8]>) -> MoqResult<Draft> {
let d = Draft::select(offered).ok_or(MoqError::UnsupportedVersion(0))?;
self.draft = Some(d);
Ok(d)
}
/// Process a peer SETUP and transition to Handshaking → Established.
pub fn accept_setup(&mut self, peer: &SetupMessage) -> MoqResult<SetupMessage> {
if !peer.versions.contains(&MOQT_DRAFT_VERSION) {
return Err(MoqError::UnsupportedVersion(
peer.versions.first().copied().unwrap_or(0),
));
/// The SETUP this endpoint sends: its implementation, the limits it
/// declares, and the JWP receipt extension when `request_jwp`.
pub fn build_setup(&self, request_jwp: bool) -> Setup {
let mut o = SetupOptions::new();
o.push(KeyValuePair::bytes(opt::MOQT_IMPLEMENTATION, implementation_name().into_bytes()));
if self.max_filter_ranges != 0 {
o.push(KeyValuePair::varint(opt::MAX_FILTER_RANGES, self.max_filter_ranges));
}
if self.max_request_updates != 0 {
o.push(KeyValuePair::varint(opt::MAX_REQUEST_UPDATES, self.max_request_updates));
}
if request_jwp {
o.push(KeyValuePair::varint(SETUP_OPTION_JWP_RECEIPTS, JWP_MOQ_EXTENSION_VERSION as u64));
}
Setup { options: o }
}
/// Whether a SETUP declares the JWP receipt extension at this crate's version.
pub fn declares_jwp(setup: &Setup) -> bool {
setup.options.extension(SETUP_OPTION_JWP_RECEIPTS).and_then(KeyValuePair::as_varint) == Some(JWP_MOQ_EXTENSION_VERSION as u64)
}
/// Take the peer's SETUP: refused where its options break the draft for a
/// peer at its end (§9.1.1, §9.1.2, §9.1.4). The JWP receipt extension is on
/// when the peer declares it at this crate's version and `we_offer` it.
pub fn on_peer_setup(&mut self, peer: &Setup, we_offer_jwp: bool) -> MoqResult<()> {
peer.check_from(self.perspective.peer(), false)
.map_err(|code| MoqError::Wire(WireError::Session { code, reason: "the peer's SETUP breaks a rule for its end" }))?;
self.state = SessionState::Handshaking;
self.negotiated_version = Some(MOQT_DRAFT_VERSION);
self.jwp_receipts_enabled = peer.jwp_receipts;
let response = self.build_setup(self.jwp_receipts_enabled, None);
let d = *self.draft.get_or_insert(Draft::D22);
self.negotiated_version = Some(d.version_code());
self.jwp_receipts_enabled = we_offer_jwp && Self::declares_jwp(peer);
self.peer_options = Some(peer.options.clone());
self.state = SessionState::Established;
Ok(response)
Ok(())
}
pub fn add_subscription(
&mut self,
subscribe_id: u64,
track_alias: u64,
require_receipts: bool,
) -> MoqResult<()> {
/// Take the peer's SETUP and produce this endpoint's, offering the JWP
/// extension exactly when the peer did.
pub fn accept_setup(&mut self, peer: &Setup) -> MoqResult<Setup> {
let offer = Self::declares_jwp(peer);
self.on_peer_setup(peer, offer)?;
Ok(self.build_setup(self.jwp_receipts_enabled))
}
/// The next Request ID this endpoint gives a request.
pub fn next_request_id(&mut self) -> MoqResult<u64> {
self.request_ids.next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))
}
/// Admit a Request ID the peer sent (INVALID_REQUEST_ID otherwise).
pub fn accept_peer_request_id(&mut self, id: u64) -> MoqResult<()> {
Ok(self.peer_request_ids.accept(id)?)
}
pub fn add_subscription(&mut self, subscribe_id: u64, track_alias: u64, require_receipts: bool) -> MoqResult<()> {
if self.state != SessionState::Established {
return Err(MoqError::InvalidSessionState(format!(
"subscribe requires Established, was {:?}",
self.state
)));
return Err(MoqError::InvalidSessionState(format!("subscribe requires Established, was {:?}", self.state)));
}
self.subscriptions.insert(
subscribe_id,
SubscriptionState {
subscribe_id,
track_alias,
require_receipts,
last_group: None,
last_object: None,
joules_consumed_micro: 0,
},
SubscriptionState { subscribe_id, track_alias, require_receipts, last_group: None, last_object: None, joules_consumed_micro: 0 },
);
self.alias_index.insert(track_alias, subscribe_id);
Ok(())
@ -131,21 +200,9 @@ impl MoqSession {
}
}
pub fn record_object(
&mut self,
track_alias: u64,
group_id: u64,
object_id: u64,
joules_micro: u64,
) -> MoqResult<()> {
let sub_id = *self
.alias_index
.get(&track_alias)
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
let sub = self
.subscriptions
.get_mut(&sub_id)
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
pub fn record_object(&mut self, track_alias: u64, group_id: u64, object_id: u64, joules_micro: u64) -> MoqResult<()> {
let sub_id = *self.alias_index.get(&track_alias).ok_or(MoqError::SubscriptionNotFound(track_alias))?;
let sub = self.subscriptions.get_mut(&sub_id).ok_or(MoqError::SubscriptionNotFound(track_alias))?;
sub.last_group = Some(group_id);
sub.last_object = Some(object_id);
sub.joules_consumed_micro = sub.joules_consumed_micro.saturating_add(joules_micro);
@ -163,32 +220,57 @@ impl MoqSession {
#[cfg(test)]
mod tests {
use super::*;
use crate::draft22::codes::SessionError;
use crate::draft22::Token;
use crate::MOQT_DRAFT_VERSION;
/// The handshake: the client's SETUP declares the receipt extension, the
/// server's answers it, and both sessions are established on -22.
#[test]
fn handshake_round_trip() {
let mut srv = MoqSession::new(SessionRole::PubSub);
let client_setup = SetupMessage {
versions: vec![MOQT_DRAFT_VERSION],
role: 0x02,
jwp_receipts: true,
peer_label: Some("client".into()),
};
let client = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
let client_setup = client.build_setup(true);
let resp = srv.accept_setup(&client_setup).unwrap();
assert_eq!(srv.state, SessionState::Established);
assert!(srv.jwp_receipts_enabled);
assert!(resp.jwp_receipts);
assert!(MoqSession::declares_jwp(&resp));
assert_eq!(srv.negotiated_version, Some(MOQT_DRAFT_VERSION));
assert_eq!(resp.options.implementation(), Some(implementation_name().as_bytes()));
// Through the codec and back.
let wire = crate::draft22::ControlMessage::Setup(resp.clone()).encode().unwrap();
assert_eq!(crate::draft22::ControlMessage::decode_exact(&wire).unwrap(), crate::draft22::ControlMessage::Setup(resp));
}
/// A version is refused at the ALPN, before any SETUP: -22 carries none in
/// SETUP. And a SETUP that breaks a rule for its sender's end is refused.
#[test]
fn rejects_unsupported_version() {
let mut srv = MoqSession::new(SessionRole::Publisher);
let bad_setup = SetupMessage {
versions: vec![0x1234_5678],
role: 0x02,
jwp_receipts: false,
peer_label: None,
};
let result = srv.accept_setup(&bad_setup);
assert!(matches!(result, Err(MoqError::UnsupportedVersion(_))));
let old: [&[u8]; 2] = [b"moq-00", b"moqt-18"];
assert!(matches!(srv.select_alpn(old), Err(MoqError::UnsupportedVersion(_))));
let ok: [&[u8]; 2] = [b"moqt-18", b"moqt-22"];
assert_eq!(srv.select_alpn(ok).unwrap(), Draft::D22);
// A client receiving PATH from the server.
let mut client = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
let mut bad = srv.build_setup(false);
bad.options.push(KeyValuePair::bytes(opt::PATH, b"/x".to_vec()));
assert!(matches!(client.on_peer_setup(&bad, false), Err(MoqError::Wire(WireError::Session { code: SessionError::INVALID_PATH, .. }))));
// A server receiving USE_ALIAS in a client's SETUP.
let mut c = client.build_setup(false);
c.options.push(KeyValuePair::bytes(opt::AUTHORIZATION_TOKEN, Token::UseAlias { alias: 1 }.to_bytes()));
assert!(matches!(srv.on_peer_setup(&c, false), Err(MoqError::Wire(WireError::Session { code: SessionError::PROTOCOL_VIOLATION, .. }))));
}
#[test]
fn request_ids_by_end() {
let mut c = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
let mut s = MoqSession::new(SessionRole::Publisher);
assert_eq!((c.next_request_id().unwrap(), c.next_request_id().unwrap()), (0, 2));
s.accept_peer_request_id(0).unwrap();
assert!(s.accept_peer_request_id(0).is_err());
assert!(s.accept_peer_request_id(1).is_err());
assert_eq!(s.next_request_id().unwrap(), 1);
}
}

View file

@ -61,9 +61,9 @@ impl TrackName {
}
}
/// Session-local short alias for a track. Established at SUBSCRIBE time and
/// used in OBJECT headers to avoid repeating the full namespace+name on every
/// object.
/// Session-local short alias for a track, chosen by the publisher in
/// SUBSCRIBE_OK or PUBLISH (`draft-ietf-moq-transport-22` §3.1.3) and used in
/// subgroup headers and datagrams to avoid repeating the full namespace+name.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TrackAlias(pub u64);
@ -74,14 +74,20 @@ pub struct GroupId(pub u64);
pub struct ObjectId(pub u64);
/// Per-object delivery status.
///
/// `draft-ietf-moq-transport-22` registers Normal (0x0), End of Group (0x3) and
/// End of Track (0x4) only (§11.1.1). The two values between are those of the
/// draft-08 era: -22 states an object or group that does not exist by a Prior
/// Object ID Gap or Prior Group ID Gap property or a fetch End of Range, and
/// [`crate::carriage`] refuses to put either value on the wire.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ObjectStatus {
/// Normal payload object.
Normal = 0,
/// Object does not exist (publisher signals a gap).
/// Object does not exist (publisher signals a gap). Draft-08 only.
DoesNotExist = 1,
/// Group does not exist.
/// Group does not exist. Draft-08 only.
GroupDoesNotExist = 2,
/// End of group sentinel.
EndOfGroup = 3,

View file

@ -1,75 +1,57 @@
//! Wire-level codec primitives for MoQT.
//! Wire-level primitives of the MOQT draft this crate implements, over `bytes`
//! buffers.
//!
//! MoQT uses QUIC-style variable-length integers (RFC 9000 §16): the two most
//! significant bits of the first byte encode the integer length (1, 2, 4, or
//! 8 bytes), the remaining bits hold the value. Strings and byte vectors are
//! length-prefixed by varint.
//! MOQT's integer is the variable-length integer of
//! `draft-ietf-moq-transport-22` §8.1 ([`crate::draft22::varint`]): the number of
//! leading 1 bits of the first byte gives the length, 1 to 9 bytes, and every
//! 64-bit value is representable. It is not QUIC's (RFC 9000 §16), which drafts
//! before -17 used and this module carried before. Byte strings are prefixed by
//! their length as such an integer.
use crate::draft22::varint;
use crate::error::{MoqError, MoqResult};
use bytes::{Buf, BufMut, Bytes, BytesMut};
/// Maximum value representable as a QUIC varint.
pub const VARINT_MAX: u64 = (1 << 62) - 1;
/// The largest value the integer carries: every `u64`.
pub const VARINT_MAX: u64 = varint::VARINT_MAX;
/// Encode an unsigned integer as a QUIC varint into `dst`.
/// Encode an unsigned integer into `dst`, in its shortest form.
pub fn encode_varint<B: BufMut>(value: u64, dst: &mut B) -> MoqResult<()> {
if value > VARINT_MAX {
return Err(MoqError::VarintOverflow);
}
if value < (1 << 6) {
dst.put_u8(value as u8);
} else if value < (1 << 14) {
dst.put_u16((value as u16) | (0b01 << 14));
} else if value < (1 << 30) {
dst.put_u32((value as u32) | (0b10 << 30));
} else {
dst.put_u64(value | (0b11u64 << 62));
}
let mut tmp = Vec::with_capacity(9);
varint::put(&mut tmp, value);
dst.put_slice(&tmp);
Ok(())
}
/// Decode a QUIC varint from `src`. Advances the cursor.
/// Decode an integer from `src`, advancing the cursor.
pub fn decode_varint<B: Buf>(src: &mut B) -> MoqResult<u64> {
if !src.has_remaining() {
return Err(MoqError::WireDecode("varint: empty buffer".into()));
}
let first = src.chunk()[0];
let len_bits = first >> 6;
let len = 1usize << len_bits;
let len = varint::len_from_first(src.chunk()[0]);
if src.remaining() < len {
return Err(MoqError::WireDecode(format!(
"varint: need {} bytes, have {}",
len,
src.remaining()
)));
return Err(MoqError::WireDecode(format!("varint: need {} bytes, have {}", len, src.remaining())));
}
let value = match len {
1 => (src.get_u8() & 0x3f) as u64,
2 => (src.get_u16() & 0x3fff) as u64,
4 => (src.get_u32() & 0x3fff_ffff) as u64,
8 => src.get_u64() & 0x3fff_ffff_ffff_ffff,
_ => unreachable!("len_bits is 2 bits"),
};
Ok(value)
let mut tmp = [0u8; 9];
src.copy_to_slice(&mut tmp[..len]);
let (v, _) = varint::get(&tmp[..len], "varint")?;
Ok(v)
}
/// Encode a byte slice prefixed by its varint length.
/// Encode a byte slice prefixed by its length.
pub fn encode_bytes<B: BufMut>(slice: &[u8], dst: &mut B) -> MoqResult<()> {
encode_varint(slice.len() as u64, dst)?;
dst.put_slice(slice);
Ok(())
}
/// Decode a varint-length-prefixed byte vector.
/// Decode a length-prefixed byte vector.
pub fn decode_bytes<B: Buf>(src: &mut B) -> MoqResult<Bytes> {
let len = decode_varint(src)? as usize;
if src.remaining() < len {
return Err(MoqError::WireDecode(format!(
"bytes: need {} bytes, have {}",
len,
src.remaining()
)));
let len = decode_varint(src)?;
if (src.remaining() as u64) < len {
return Err(MoqError::WireDecode(format!("bytes: need {} bytes, have {}", len, src.remaining())));
}
let len = len as usize;
let mut buf = BytesMut::with_capacity(len);
buf.resize(len, 0);
src.copy_to_slice(&mut buf);
@ -83,6 +65,21 @@ pub trait WireCodec: Sized {
fn decode(src: &mut Bytes) -> MoqResult<Self>;
}
impl WireCodec for crate::draft22::ControlMessage {
fn encode(&self, dst: &mut BytesMut) -> MoqResult<()> {
dst.put_slice(&crate::draft22::ControlMessage::encode(self)?);
Ok(())
}
fn decode(src: &mut Bytes) -> MoqResult<Self> {
let mut r = crate::draft22::io::Reader::new(src);
let m = crate::draft22::ControlMessage::decode(&mut r)?;
let used = r.position();
src.advance(used);
Ok(m)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -98,16 +95,21 @@ mod tests {
#[test]
fn varint_boundaries() {
for v in [0u64, 1, 63, 64, 16_383, 16_384, 1_073_741_823, 1_073_741_824, VARINT_MAX] {
for v in [0u64, 1, 127, 128, 16_383, 16_384, 1_073_741_823, 1_073_741_824, (1 << 56) - 1, 1 << 56, VARINT_MAX] {
roundtrip(v);
}
}
/// Before -17 the integer stopped at 2^62-1 and a larger value was an error.
/// In -22 every u64 has a form, so there is no overflow to refuse.
#[test]
fn varint_overflow() {
let mut buf = BytesMut::new();
let result = encode_varint(VARINT_MAX + 1, &mut buf);
assert!(matches!(result, Err(MoqError::VarintOverflow)));
fn every_u64_encodes() {
for v in [(1u64 << 62) - 1, 1 << 62, u64::MAX] {
roundtrip(v);
}
let mut b = BytesMut::new();
encode_varint(u64::MAX, &mut b).unwrap();
assert_eq!(b.len(), 9);
}
#[test]

View file

@ -0,0 +1,502 @@
//! Generated round trips and hostile input for the `draft-ietf-moq-transport-22`
//! codec.
//!
//! A seeded generator (no randomness from outside the test, so a failure
//! reproduces) builds valid control messages of every type, subgroup and fetch
//! streams in both Group Orders, datagrams and `WAI_RECEIPT` values. For each,
//! encode → decode must give the value back, and encode again the same bytes.
//!
//! Then the encodings, and every vector of the corpus, are mutated (bits
//! flipped, bytes cut, inserted, overwritten, lengths swapped for large ones)
//! and fed to every decoder: control messages, whole streams in both orders,
//! the incremental decoder in random pieces, datagrams, property lists, tokens,
//! the `WAI_RECEIPT` value. Each must return, a value or a refusal; a panic
//! fails the test with the input that caused it.
use joule_moq::carriage::WaiReceiptProperty;
use joule_moq::draft22::control::*;
use joule_moq::draft22::data::{encode_fetch_stream, encode_subgroup_stream, FetchObject, Limits};
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties, SetupOptions};
use joule_moq::draft22::params::{ctx, param, ParamContext, Range, RangeFilter};
use joule_moq::draft22::{
decode_uni_stream, varint, Datagram, DatagramContent, FetchEntry, FetchHeader, GroupOrder, Location, LocationFilter, ObjectDatagram, ObjectStatus,
Parameter, Parameters, PublishDoneCode, RangeEnd, RequestErrorCode, StreamHeader, SubgroupHeader, SubgroupId, SubgroupObject, Token, UniEvent,
UniStreamDecoder,
};
use joule_moq::track::{TrackName, TrackNamespace};
/// xorshift64*: small, seeded, and the same on every host.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn below(&mut self, n: u64) -> u64 {
if n == 0 {
0
} else {
self.next() % n
}
}
fn chance(&mut self, num: u64, den: u64) -> bool {
self.below(den) < num
}
/// A value of a random width, so every varint length appears.
fn wide(&mut self) -> u64 {
let bits = self.below(65) as u32;
if bits == 0 {
0
} else if bits == 64 {
self.next()
} else {
self.next() & ((1u64 << bits) - 1)
}
}
fn small(&mut self) -> u64 {
self.below(1000)
}
fn bytes(&mut self, max: u64) -> Vec<u8> {
let n = self.below(max + 1);
(0..n).map(|_| self.next() as u8).collect()
}
fn nonempty(&mut self, max: u64) -> Vec<u8> {
let mut b = self.bytes(max.max(1) - 1);
b.push(self.next() as u8);
b
}
}
fn namespace(r: &mut Rng) -> TrackNamespace {
let n = r.below(5);
TrackNamespace((0..n).map(|_| r.nonempty(8)).collect())
}
fn token(r: &mut Rng) -> Token {
match r.below(4) {
0 => Token::Delete { alias: r.wide() },
1 => Token::Register { alias: r.wide(), token_type: r.wide(), value: r.bytes(12) },
2 => Token::UseAlias { alias: r.wide() },
_ => Token::UseValue { token_type: r.wide(), value: r.bytes(12) },
}
}
fn location_filter(r: &mut Rng) -> LocationFilter {
let start = Location::new(r.wide() >> 1, r.wide());
let delta = r.below(1000);
match r.below(6) {
0 => LocationFilter::None,
1 => LocationFilter::RelativeStart { start_group: r.wide() },
2 => LocationFilter::AbsoluteStart { start },
3 => LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta: delta },
4 => LocationFilter::AbsoluteRange { start, end_group_delta: delta, end_object: r.wide() },
_ => LocationFilter::NextObject,
}
}
fn ranges(r: &mut Rng, max_value: u64) -> Vec<Range> {
let n = r.below(4);
let mut out = Vec::new();
let mut at = r.below(max_value.min(50) + 1);
for i in 0..n {
let open = i + 1 == n && r.chance(1, 3);
let end = if open { None } else { Some((at + r.below(20)).min(max_value)) };
out.push(Range { start: at, end });
match end {
Some(e) => at = e + r.below(5),
None => break,
}
if at > max_value {
break;
}
}
out
}
fn range_filter(r: &mut Rng, ty: u64, set_id: u8, may_remove: bool) -> Parameter {
if may_remove && r.chance(1, 8) {
return Parameter::RangeFilter { ty, filter: None };
}
let property_type = if ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER { Some(r.wide() & !1) } else { None };
let max = if ty == param::PRIORITY_FILTER { 255 } else { u64::MAX >> 1 };
Parameter::RangeFilter { ty, filter: Some(RangeFilter { set_id, property_type, ranges: ranges(r, max) }) }
}
/// A valid parameter block for `c`, in wire order.
fn params(r: &mut Rng, c: &ParamContext) -> Parameters {
let mut out = Vec::new();
for &ty in c.allowed {
if !r.chance(1, 3) {
continue;
}
let p = match ty {
param::OBJECT_DELIVERY_TIMEOUT => Parameter::ObjectDeliveryTimeout(r.wide()),
param::AUTHORIZATION_TOKEN => {
for _ in 0..r.below(2) {
out.push(Parameter::AuthorizationToken(token(r)));
}
Parameter::AuthorizationToken(token(r))
}
param::RENDEZVOUS_TIMEOUT => Parameter::RendezvousTimeout(r.wide()),
param::SUBGROUP_DELIVERY_TIMEOUT => Parameter::SubgroupDeliveryTimeout(r.wide()),
param::EXPIRES => Parameter::Expires(r.wide()),
param::LARGEST_OBJECT => Parameter::LargestObject(Location::new(r.wide(), r.wide())),
param::FILL_TIMEOUT => Parameter::FillTimeout(r.wide()),
param::FORWARD => Parameter::Forward(r.chance(1, 2)),
param::SUBSCRIBER_PRIORITY => Parameter::SubscriberPriority(r.next() as u8),
param::LOCATION_FILTER => Parameter::LocationFilter(location_filter(r)),
param::GROUP_ORDER => Parameter::GroupOrder(if r.chance(1, 2) { GroupOrder::Ascending } else { GroupOrder::Descending }),
param::FILL_PARAMETERS => Parameter::FillParameters(params(r, &ctx::FILL_PARAMETERS_INNER)),
t if param::is_range_filter(t) => {
// Up to three, each with a SetID of its own.
let n = 1 + r.below(3);
for s in 1..n {
out.push(range_filter(r, t, s as u8, false));
}
// A removal (Length 0) has no SetID, so only one may be in a block.
range_filter(r, t, 0, true)
}
param::NEW_GROUP_REQUEST => Parameter::NewGroupRequest(r.wide()),
param::TRACK_NAMESPACE_PREFIX => Parameter::TrackNamespacePrefix(namespace(r)),
param::INCLUDE_PROPERTIES => Parameter::IncludeProperties(r.chance(1, 2)),
other => panic!("no generator for {other:#x}"),
};
out.push(p);
}
out.sort_by_key(|p| p.type_code());
Parameters(out)
}
/// Pairs that pass the checks of an object's or a track's properties: types
/// from the application ranges and the timeouts, an Immutable Properties pair
/// holding more of the same.
fn safe_pairs(r: &mut Rng, nest: bool) -> Vec<KeyValuePair> {
let pool = [0x02u64, 0x06, 0x78, 0x79, 0x7e, 0x7f, 0x3800, 0x3a40, 0x3a41, 0x3a45, 0x3fff];
let mut out: Vec<KeyValuePair> = (0..r.below(4))
.map(|_| {
let ty = pool[r.below(pool.len() as u64) as usize];
if ty.is_multiple_of(2) {
KeyValuePair::varint(ty, r.wide())
} else {
KeyValuePair::bytes(ty, r.bytes(16))
}
})
.collect();
if nest && r.chance(1, 3) {
let inner = safe_pairs(r, false);
let mut b = Vec::new();
joule_moq::draft22::kvp::encode_pairs(&inner, &mut b).unwrap();
out.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
}
out.sort_by_key(|k| k.ty);
out
}
fn properties(r: &mut Rng) -> Properties {
Properties(safe_pairs(r, true))
}
fn setup(r: &mut Rng) -> Setup {
let mut o = SetupOptions::new();
for &(ty, bytes) in &[(0x01u64, true), (0x04, false), (0x05, true), (0x06, false), (0x07, true), (0x08, false), (0x9d, true), (0x3a46, false)] {
if r.chance(1, 2) {
o.push(if bytes { KeyValuePair::bytes(ty, r.bytes(20)) } else { KeyValuePair::varint(ty, r.wide()) });
}
}
for _ in 0..r.below(3) {
o.push(KeyValuePair::bytes(0x03, token(r).to_bytes()));
}
o.0.sort_by_key(|p| p.ty);
Setup { options: o }
}
fn track_request(r: &mut Rng, c: &ParamContext) -> TrackRequest {
TrackRequest { request_id: r.wide(), namespace: namespace(r), name: TrackName(r.bytes(8)), params: params(r, c) }
}
fn ns_request(r: &mut Rng, c: &ParamContext) -> NamespaceRequest {
NamespaceRequest { request_id: r.wide(), prefix: namespace(r), params: params(r, c) }
}
fn control(r: &mut Rng, which: u64) -> ControlMessage {
match which % 19 {
0 => ControlMessage::Setup(setup(r)),
1 => ControlMessage::Goaway(Goaway { new_session_uri: r.bytes(40), timeout_ms: r.wide() }),
2 => ControlMessage::Subscribe(track_request(r, &ctx::SUBSCRIBE)),
3 => ControlMessage::SubscribeOk(SubscribeOk { track_alias: r.wide(), params: params(r, &ctx::SUBSCRIBE_OK), track_properties: properties(r) }),
4 => ControlMessage::PublishStateNotify(params(r, &ctx::PUBLISH_STATE_NOTIFY)),
5 => ControlMessage::Publish(Publish {
request_id: r.wide(),
namespace: namespace(r),
name: TrackName(r.bytes(8)),
track_alias: r.wide(),
params: params(r, &ctx::PUBLISH),
track_properties: properties(r),
}),
6 => ControlMessage::PublishDone(PublishDone { status: PublishDoneCode(r.wide()), stream_count: r.wide(), reason: r.bytes(30) }),
7 => ControlMessage::Fetch(track_request(r, &ctx::FETCH)),
8 => ControlMessage::FetchOk(FetchOk { end_of_track: r.chance(1, 2), end_location: Location::new(r.wide(), r.wide()), params: Parameters::new(), track_properties: properties(r) }),
9 => ControlMessage::TrackStatus(track_request(r, &ctx::TRACK_STATUS)),
10 => ControlMessage::PublishNamespace(ns_request(r, &ctx::PUBLISH_NAMESPACE)),
11 => ControlMessage::SubscribeNamespace(ns_request(r, &ctx::SUBSCRIBE_NAMESPACE)),
12 => ControlMessage::SubscribeTracks(ns_request(r, &ctx::SUBSCRIBE_TRACKS)),
13 => ControlMessage::Namespace(namespace(r)),
14 => ControlMessage::NamespaceDone(namespace(r)),
15 => ControlMessage::PublishSkipped(PublishSkipped { suffix: namespace(r), name: TrackName(r.bytes(8)) }),
16 => ControlMessage::RequestUpdate(RequestUpdate { request_id: r.wide(), params: params(r, &ctx::REQUEST_UPDATE) }),
17 => ControlMessage::RequestOk(RequestOk { params: params(r, &ctx::REQUEST_OK), track_properties: properties(r) }),
_ => {
let redirect = r.chance(1, 3);
ControlMessage::RequestError(RequestError {
code: if redirect { RequestErrorCode::REDIRECT } else { RequestErrorCode(r.wide() & !0x34 | (r.below(2) * 0x100)) },
retry_interval: r.wide(),
reason: r.bytes(30),
redirect: redirect.then(|| {
let ns = namespace(r);
Redirect { connect_uri: r.bytes(30), namespace: ns, name: TrackName(r.bytes(8)) }
}),
})
}
}
}
fn subgroup(r: &mut Rng) -> (SubgroupHeader, Vec<SubgroupObject>) {
let h = SubgroupHeader {
track_alias: r.wide(),
group_id: r.wide(),
subgroup_id: match r.below(3) {
0 => SubgroupId::Zero,
1 => SubgroupId::FirstObjectId,
_ => SubgroupId::Explicit(r.wide()),
},
publisher_priority: r.chance(1, 2).then(|| r.next() as u8),
properties: r.chance(1, 2),
end_of_group: r.chance(1, 2),
first_object: r.chance(1, 2),
};
let mut id = r.small();
let objs = (0..r.below(6))
.map(|i| {
if i > 0 {
id += 1 + r.below(4);
}
let status = match r.below(5) {
0 => ObjectStatus::EndOfGroup,
1 => ObjectStatus::EndOfTrack,
_ => ObjectStatus::Normal,
};
let normal = status == ObjectStatus::Normal;
// Object properties: no Prior Group or Object ID Gap, so no gap can pass its ID.
let properties = if h.properties && normal { properties(r) } else { Properties::new() };
let payload = if normal { r.bytes(24) } else { vec![] };
SubgroupObject { object_id: id, properties, status, payload }
})
.collect();
(h, objs)
}
fn fetch(r: &mut Rng, order: GroupOrder) -> Vec<FetchEntry> {
let mut g: u64 = match order {
GroupOrder::Ascending => r.small(),
GroupOrder::Descending => 10_000 + r.small(),
};
let mut o: u64 = r.small();
let mut first = true;
let mut out = Vec::new();
for _ in 0..r.below(8) {
let move_group = first || r.chance(1, 3);
if move_group && !first {
let step = 1 + r.below(5);
g = match order {
GroupOrder::Ascending => g + step,
GroupOrder::Descending => g - step,
};
o = r.small();
} else if !first {
o += 1 + r.below(3);
}
if move_group && r.chance(1, 4) {
out.push(FetchEntry::EndOfRange { kind: [RangeEnd::NonExistent, RangeEnd::Unknown, RangeEnd::TimedOut][r.below(3) as usize], location: Location::new(g, o) });
} else {
out.push(FetchEntry::Object(FetchObject {
group_id: g,
object_id: o,
subgroup_id: r.chance(4, 5).then(|| r.below(4)),
publisher_priority: r.below(3) as u8,
properties: r.chance(1, 3).then(|| properties(r)),
payload: r.bytes(16),
}));
}
first = false;
}
out
}
fn datagram(r: &mut Rng) -> ObjectDatagram {
let status = r.chance(1, 3);
let st = [ObjectStatus::Normal, ObjectStatus::EndOfGroup, ObjectStatus::EndOfTrack][r.below(3) as usize];
let with_props = r.chance(1, 2) && (!status || st == ObjectStatus::Normal);
let mut props = properties(r);
if props.is_empty() {
props.push(KeyValuePair::varint(0x3a40, r.wide()));
}
ObjectDatagram {
track_alias: r.wide(),
group_id: r.wide(),
object_id: if r.chance(1, 3) { 0 } else { r.wide() },
publisher_priority: r.chance(1, 2).then(|| r.next() as u8),
properties: with_props.then_some(props),
end_of_group: !status && r.chance(1, 2),
content: if status { DatagramContent::Status(st) } else { DatagramContent::Payload(r.bytes(24)) },
}
}
/// Every decoder, on bytes that may be anything: each must return.
fn feed_everything(b: &[u8], r: &mut Rng) {
let _ = varint::get(b, "x");
let _ = ControlMessage::decode_exact(b);
let _ = decode_uni_stream(b, GroupOrder::Ascending);
let _ = decode_uni_stream(b, GroupOrder::Descending);
let _ = Datagram::decode(b);
let _ = Properties::decode(b, "x").map(|p| (p.check_object(0, 0), p.check_track(), p.immutable()));
let _ = Token::decode(b);
let _ = WaiReceiptProperty::from_bytes(b).map(|p| p.check());
let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default());
let mut at = 0;
'feed: while at < b.len() {
let n = (1 + r.below(9) as usize).min(b.len() - at);
d.push(&b[at..at + n]);
at += n;
loop {
match d.next() {
Ok(Some(_)) => {}
Ok(None) => break,
Err(_) => break 'feed,
}
}
}
let _ = d.finish();
}
fn mutate(b: &[u8], r: &mut Rng) -> Vec<u8> {
let mut m = b.to_vec();
for _ in 0..1 + r.below(3) {
if m.is_empty() {
m.push(r.next() as u8);
continue;
}
let i = r.below(m.len() as u64) as usize;
match r.below(6) {
0 => m[i] ^= 1 << r.below(8),
1 => m.truncate(i),
2 => m.insert(i, r.next() as u8),
3 => m[i] = r.next() as u8,
4 => {
// A length or a count swapped for a huge one.
let big = varint::to_vec(u64::MAX - r.below(3));
m.splice(i..i + 1, big);
}
_ => m[i] = [0x00, 0x7f, 0x80, 0xbf, 0xc0, 0xfe, 0xff][r.below(7) as usize],
}
}
m
}
#[test]
fn encode_decode_encode_is_the_identity() {
let mut r = Rng(0x6d6f_7174_2d32_3201);
for i in 0..4000u64 {
// Control messages, each type in turn.
let m = control(&mut r, i);
let b = m.encode().unwrap_or_else(|e| panic!("{} did not encode: {e}\n{m:?}", m.name()));
let back = ControlMessage::decode_exact(&b).unwrap_or_else(|e| panic!("{} did not decode: {e}\n{}", m.name(), json_hex(&b)));
assert_eq!(back, m, "{}", json_hex(&b));
assert_eq!(back.encode().unwrap(), b);
// A subgroup stream.
let (h, objs) = subgroup(&mut r);
let b = encode_subgroup_stream(&h, &objs).unwrap();
let ev = decode_uni_stream(&b, GroupOrder::Ascending).unwrap_or_else(|e| panic!("subgroup: {e}\n{}", json_hex(&b)));
let mut want = vec![UniEvent::Header(StreamHeader::Subgroup(h))];
want.extend(objs.iter().cloned().map(UniEvent::SubgroupObject));
assert_eq!(ev, want);
assert_eq!(encode_subgroup_stream(&h, &objs).unwrap(), b);
// A fetch stream, in each order.
for order in [GroupOrder::Ascending, GroupOrder::Descending] {
let entries = fetch(&mut r, order);
let fh = FetchHeader { request_id: r.wide() };
let b = encode_fetch_stream(&fh, order, &entries).unwrap_or_else(|e| panic!("fetch did not encode: {e}\n{entries:?}"));
let ev = decode_uni_stream(&b, order).unwrap_or_else(|e| panic!("fetch: {e}\n{}", json_hex(&b)));
let mut want = vec![UniEvent::Header(StreamHeader::Fetch(fh))];
want.extend(entries.iter().cloned().map(UniEvent::FetchEntry));
assert_eq!(ev, want);
}
// A datagram.
let d = datagram(&mut r);
let b = d.encode().unwrap_or_else(|e| panic!("datagram did not encode: {e}\n{d:?}"));
assert_eq!(Datagram::decode(&b).unwrap(), Datagram::Object(d.clone()));
// A WAI_RECEIPT value.
let mut ch = [0u8; 32];
ch.iter_mut().for_each(|x| *x = r.next() as u8);
let p = WaiReceiptProperty { content_hash: ch, joules_micro: r.wide(), acquisition: String::from_utf8_lossy(&r.bytes(20)).into_owned(), acquisition_evidence: r.bytes(20), group_receipt: ch };
assert_eq!(WaiReceiptProperty::from_bytes(&p.to_bytes()).unwrap(), p);
// Every varint length.
let v = r.wide();
let len = varint::encoded_len(v) + r.below((10 - varint::encoded_len(v)) as u64) as usize;
let mut vb = Vec::new();
varint::put_with_len(&mut vb, v, len.min(9)).unwrap();
assert_eq!(varint::get(&vb, "v").unwrap(), (v, vb.len()));
}
}
fn json_hex(b: &[u8]) -> String {
joule_moq::draft22::json::hex(b)
}
#[test]
fn no_input_panics_any_decoder() {
let mut r = Rng(0x686f_7374_696c_6501);
let corpus: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/draft-22/vectors.json")).unwrap()).unwrap();
let mut seeds: Vec<Vec<u8>> = corpus["vectors"]
.as_array()
.unwrap()
.iter()
.map(|v| {
let h = v["hex"].as_str().unwrap();
(0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect()
})
.collect();
for i in 0..300u64 {
seeds.push(control(&mut r, i).encode().unwrap());
let (h, objs) = subgroup(&mut r);
seeds.push(encode_subgroup_stream(&h, &objs).unwrap());
seeds.push(datagram(&mut r).encode().unwrap());
}
assert!(!seeds.is_empty());
let mut fed = 0u64;
for round in 0..25 {
for s in &seeds {
let m = if round == 0 { s.clone() } else { mutate(s, &mut r) };
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut r2 = Rng(m.len() as u64 + 1);
feed_everything(&m, &mut r2);
}));
assert!(caught.is_ok(), "a decoder panicked on {}", json_hex(&m));
fed += 1;
}
// Bytes with no structure at all.
let noise = r.bytes(64);
feed_everything(&noise, &mut r);
}
assert!(fed > 0);
}

View file

@ -0,0 +1,768 @@
//! The `draft-ietf-moq-transport-22` vector corpus (`tests/vectors/draft-22/`).
//!
//! Every vector is built here from typed values, deterministically: the bytes
//! are what this crate's encoder makes of the value (or, for a negative vector
//! and the few forms the encoder never makes, bytes written out by hand), and
//! the expected reading is the value itself in the JSON view of
//! `joule_moq::draft22::json`, or, for a negative vector, the refusal it must
//! meet and the draft's consequence. Expectations are stated here, never read
//! back from the decoder.
//!
//! This test then holds the crate to the corpus:
//!
//! - the committed `vectors.json` is exactly what this file generates (run with
//! `JOULE_MOQ_WRITE_VECTORS=1` to rewrite it);
//! - each good vector decodes to its stated reading, and, where it is the form
//! the encoder makes, re-encodes to its bytes;
//! - each negative vector is refused with its stated kind and consequence, by
//! the whole-stream decoder and, for streams, by the incremental one too.
//!
//! `vectors/draft-22/moqt22_check.py` decodes the same corpus independently.
use joule_moq::carriage::WaiReceiptProperty;
use joule_moq::draft22::control::*;
use joule_moq::draft22::data::{
encode_control_stream, encode_fetch_stream, encode_padding_stream, encode_subgroup_stream, FetchCodec, FetchObject, Limits, SubgroupCodec,
};
use joule_moq::draft22::kvp::{opt, prop, KeyValuePair, Properties, SetupOptions};
use joule_moq::draft22::params::{param, Range, RangeFilter};
use joule_moq::draft22::{
decode_uni_stream, json, varint, Datagram, DatagramContent, FetchEntry, FetchHeader, GroupOrder, Location, LocationFilter, ObjectDatagram,
ObjectStatus, Parameter, Parameters, PublishDoneCode, RangeEnd, RequestErrorCode, StreamHeader, SubgroupHeader, SubgroupId, SubgroupObject,
Token, UniEvent, UniStreamDecoder, WireError,
};
use joule_moq::track::{TrackName, TrackNamespace};
use serde_json::{json, Value};
const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/draft-22/vectors.json");
fn hx(b: &[u8]) -> String {
json::hex(b)
}
fn ns(fields: &[&str]) -> TrackNamespace {
TrackNamespace::from_strs(fields)
}
fn name(s: &str) -> TrackName {
TrackName::from_str(s)
}
fn kv(ty: u64, v: u64) -> KeyValuePair {
KeyValuePair::varint(ty, v)
}
fn kb(ty: u64, b: &[u8]) -> KeyValuePair {
KeyValuePair::bytes(ty, b.to_vec())
}
/// A property list in wire order (by type, stably), as a decoder reads it.
fn props(mut ps: Vec<KeyValuePair>) -> Properties {
ps.sort_by_key(|k| k.ty);
Properties(ps)
}
/// Properties with an Immutable Properties pair holding `inner`.
fn with_immutable(mut outer: Vec<KeyValuePair>, inner: Vec<KeyValuePair>) -> Properties {
let mut b = Vec::new();
joule_moq::draft22::kvp::encode_pairs(&inner, &mut b).unwrap();
outer.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
let mut p = Properties(outer);
p.0.sort_by_key(|k| k.ty);
p
}
fn rf(ty: u64, set_id: u8, property_type: Option<u64>, ranges: &[(u64, Option<u64>)]) -> Parameter {
Parameter::RangeFilter {
ty,
filter: Some(RangeFilter { set_id, property_type, ranges: ranges.iter().map(|&(start, end)| Range { start, end }).collect() }),
}
}
/// Sort a parameter list as the wire orders it (by type, stably), so the
/// stated reading is the one a decoder gives.
fn sorted(ps: Vec<Parameter>) -> Parameters {
let mut v = ps;
v.sort_by_key(|p| p.type_code());
Parameters(v)
}
struct Corpus {
vectors: Vec<Value>,
}
impl Corpus {
fn push(&mut self, name: &str, kind: &str, bytes: &[u8], canonical: bool, context: Option<Value>, expect: Value) {
let mut v = json!({"name": name, "kind": kind, "hex": hx(bytes), "canonical": canonical, "expect": expect});
if let Some(c) = context {
v["context"] = c;
}
self.vectors.push(v);
}
fn control(&mut self, name: &str, m: ControlMessage) {
let b = m.encode().expect(name);
self.push(name, "control", &b, true, None, json::control(&m));
}
fn stream(&mut self, name: &str, bytes: Vec<u8>, order: GroupOrder, events: Vec<UniEvent>) {
let expect = Value::Array(events.iter().map(json::uni_event).collect());
self.push(name, "uni-stream", &bytes, true, Some(json!({"group_order": order as u8})), expect);
}
fn datagram(&mut self, name: &str, d: Datagram, bytes: Vec<u8>, canonical: bool) {
self.push(name, "datagram", &bytes, canonical, None, json::datagram(&d));
}
fn bad(&mut self, name: &str, kind: &str, bytes: &[u8], context: Option<Value>, error: &str, consequence: &str) {
self.push(name, kind, bytes, false, context, json!({"error": error, "consequence": consequence}));
}
}
const PV: &str = "session:PROTOCOL_VIOLATION";
/// Frame a control message body by hand: type, a 16-bit length, the body.
fn frame(ty: u64, body: &[u8]) -> Vec<u8> {
let mut out = varint::to_vec(ty);
out.extend_from_slice(&(body.len() as u16).to_be_bytes());
out.extend_from_slice(body);
out
}
fn v(x: u64) -> Vec<u8> {
varint::to_vec(x)
}
fn cat(parts: &[&[u8]]) -> Vec<u8> {
parts.concat()
}
fn wai_receipt(content: u8, joules: u64, class: &str, evidence: Vec<u8>, group: u8) -> WaiReceiptProperty {
WaiReceiptProperty { content_hash: [content; 32], joules_micro: joules, acquisition: class.into(), acquisition_evidence: evidence, group_receipt: [group; 32] }
}
fn wai_json(p: &WaiReceiptProperty) -> Value {
json!({
"content_hash": hx(&p.content_hash),
"joules_micro": p.joules_micro,
"acquisition": p.acquisition,
"acquisition_evidence": hx(&p.acquisition_evidence),
"group_receipt": hx(&p.group_receipt),
})
}
fn build() -> Vec<Value> {
let mut c = Corpus { vectors: Vec::new() };
// ── §8.1 the variable-length integer: Table 4 of the draft ──
for (h, val, shortest) in [
("25", 37u64, true),
("8025", 37, false),
("bbbd", 15_293, true),
("ed7f3e7d", 226_442_877, true),
("faa1a0e403d8", 2_893_212_287_960, true),
("fc8998abc66bc0", 151_288_809_941_952, true),
("fefa318fa8e3ca11", 70_423_237_261_249_041, true),
("ffffffffffffffffff", u64::MAX, true),
] {
let b: Vec<u8> = (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect();
c.push(&format!("varint {h}"), "varint", &b, shortest, None, json!({"value": val, "length": b.len()}));
}
c.push("varint 0 in three bytes", "varint", &[0xc0, 0x00, 0x00], false, None, json!({"value": 0, "length": 3}));
c.bad("varint cut short", "varint", &[0xe0, 0x01], None, "truncated", PV);
// ── §9.1 SETUP ──
let mut client = SetupOptions::new();
client
.push(kb(opt::PATH, b"/live?room=1"))
.push(kb(opt::AUTHORIZATION_TOKEN, &Token::Register { alias: 1, token_type: 0, value: b"tok".to_vec() }.to_bytes()))
.push(kv(opt::MAX_AUTH_TOKEN_CACHE_SIZE, 1024))
.push(kb(opt::AUTHORITY, b"relay.example:443"))
.push(kv(opt::MAX_FILTER_RANGES, 4))
.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0"))
.push(kv(opt::MAX_REQUEST_UPDATES, 2))
.push(kb(0x9d, b"grease"))
.push(kv(0x3a46, 2));
let mut client_sorted = client.clone();
client_sorted.0.sort_by_key(|p| p.ty);
c.push("SETUP from a client", "control", &ControlMessage::Setup(Setup { options: client }).encode().unwrap(), true, None,
json::control(&ControlMessage::Setup(Setup { options: client_sorted })));
let mut server = SetupOptions::new();
server.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0"));
c.control("SETUP from a server", ControlMessage::Setup(Setup { options: server }));
c.control("SETUP with no options", ControlMessage::Setup(Setup::default()));
// ── §9.2 GOAWAY ──
c.control("GOAWAY with a new session URI", ControlMessage::Goaway(Goaway { new_session_uri: b"moqt://relay.example/next".to_vec(), timeout_ms: 5000 }));
c.control("GOAWAY with no URI and no timeout", ControlMessage::Goaway(Goaway::default()));
// ── §9.6 SUBSCRIBE, with every parameter it may carry ──
let every = sorted(vec![
Parameter::ObjectDeliveryTimeout(250),
Parameter::AuthorizationToken(Token::UseValue { token_type: 0, value: b"jwt".to_vec() }),
Parameter::RendezvousTimeout(1000),
Parameter::SubgroupDeliveryTimeout(500),
Parameter::Forward(true),
Parameter::SubscriberPriority(10),
Parameter::LocationFilter(LocationFilter::NextObject),
Parameter::GroupOrder(GroupOrder::Descending),
Parameter::FillParameters(sorted(vec![Parameter::LocationFilter(LocationFilter::RelativeStart { start_group: 1 }), Parameter::FillTimeout(100)])),
rf(param::SUBGROUP_FILTER, 0, None, &[(0, Some(2))]),
rf(param::OBJECTID_FILTER, 0, None, &[(3, Some(5)), (10, None)]),
rf(param::PRIORITY_FILTER, 1, None, &[(0, Some(127))]),
rf(param::OBJECT_PROPERTY_FILTER, 0, Some(0x3a40), &[(0, Some(2))]),
Parameter::NewGroupRequest(0),
Parameter::IncludeProperties(true),
]);
c.control("SUBSCRIBE with every parameter", ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["example", "cam1"]), name: name("video"), params: every }));
c.control("SUBSCRIBE with no parameters, an empty namespace", ControlMessage::Subscribe(TrackRequest { request_id: 2, namespace: TrackNamespace(vec![]), name: name("t"), params: Parameters::new() }));
// A Request ID in a longer form than it needs: the reading is the same.
let sub = ControlMessage::Subscribe(TrackRequest { request_id: 5, namespace: ns(&["a"]), name: name("b"), params: Parameters::new() });
let mut body = Vec::new();
varint::put_with_len(&mut body, 5, 3).unwrap();
body.extend_from_slice(&cat(&[&v(1), &v(1), b"a", &v(1), b"b", &v(0)]));
c.push("SUBSCRIBE whose Request ID takes three bytes", "control", &frame(msg::SUBSCRIBE, &body), false, None, json::control(&sub));
// ── §9.7 SUBSCRIBE_OK ──
c.control(
"SUBSCRIBE_OK with track properties",
ControlMessage::SubscribeOk(SubscribeOk {
track_alias: 7,
params: sorted(vec![Parameter::Expires(30_000), Parameter::LargestObject(Location::new(5, 2))]),
track_properties: with_immutable(
vec![
kv(prop::OBJECT_DELIVERY_TIMEOUT, 200),
kv(prop::MAX_CACHE_DURATION, 10_000),
kv(prop::DEFAULT_PUBLISHER_PRIORITY, 64),
kv(prop::DEFAULT_PUBLISHER_GROUP_ORDER, 1),
kv(prop::DYNAMIC_GROUPS, 1),
kb(0x3a45, b"x"),
],
vec![kv(0x78, 5)],
),
}),
);
// ── §9.10 PUBLISH_STATE_NOTIFY ──
c.control(
"PUBLISH_STATE_NOTIFY",
ControlMessage::PublishStateNotify(sorted(vec![
Parameter::LargestObject(Location::new(9, 0)),
Parameter::Forward(false),
Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(9, 0) }),
])),
);
// ── §9.8 PUBLISH ──
c.control(
"PUBLISH",
ControlMessage::Publish(Publish {
request_id: 1,
namespace: ns(&["example", "cam1"]),
name: name("audio"),
track_alias: 3,
params: sorted(vec![
Parameter::Expires(60_000),
Parameter::LargestObject(Location::new(4, 9)),
Parameter::Forward(true),
Parameter::GroupOrder(GroupOrder::Ascending),
Parameter::LocationFilter(LocationFilter::AbsoluteStartGroupEnd { start: Location::new(4, 0), end_group_delta: 2 }),
]),
track_properties: props(vec![kv(prop::DYNAMIC_GROUPS, 0)]),
}),
);
// ── §9.9 PUBLISH_DONE ──
c.control("PUBLISH_DONE", ControlMessage::PublishDone(PublishDone { status: PublishDoneCode::TRACK_ENDED, stream_count: 3, reason: b"done".to_vec() }));
c.control("PUBLISH_DONE with a stream count of 2^64-1", ControlMessage::PublishDone(PublishDone { status: PublishDoneCode::TOO_FAR_BEHIND, stream_count: u64::MAX, reason: vec![] }));
// ── §9.11 FETCH, §9.12 FETCH_OK ──
c.control(
"FETCH",
ControlMessage::Fetch(TrackRequest {
request_id: 4,
namespace: ns(&["example", "cam1"]),
name: name("video"),
params: sorted(vec![
Parameter::AuthorizationToken(Token::UseAlias { alias: 1 }),
Parameter::FillTimeout(0),
Parameter::SubscriberPriority(200),
Parameter::LocationFilter(LocationFilter::AbsoluteRange { start: Location::new(1, 0), end_group_delta: 3, end_object: 7 }),
Parameter::GroupOrder(GroupOrder::Descending),
Parameter::IncludeProperties(false),
]),
}),
);
c.control(
"FETCH_OK at the end of the track",
ControlMessage::FetchOk(FetchOk { end_of_track: true, end_location: Location::new(4, 7), params: Parameters::new(), track_properties: props(vec![kv(prop::DEFAULT_PUBLISHER_PRIORITY, 128)]) }),
);
// ── §9.13 TRACK_STATUS ──
c.control(
"TRACK_STATUS",
ControlMessage::TrackStatus(TrackRequest {
request_id: 6,
namespace: ns(&["example"]),
name: name("cam1"),
params: sorted(vec![Parameter::AuthorizationToken(Token::Register { alias: 2, token_type: 0x9d, value: b"abc".to_vec() }), Parameter::IncludeProperties(true)]),
}),
);
// ── §9.14, §9.15, §9.18 namespace requests ──
c.control(
"PUBLISH_NAMESPACE",
ControlMessage::PublishNamespace(NamespaceRequest { request_id: 3, prefix: ns(&["example", "cam1"]), params: sorted(vec![Parameter::AuthorizationToken(Token::UseValue { token_type: 1, value: vec![0xde, 0xad] })]) }),
);
c.control("SUBSCRIBE_NAMESPACE", ControlMessage::SubscribeNamespace(NamespaceRequest { request_id: 8, prefix: ns(&["example"]), params: Parameters::new() }));
c.control(
"SUBSCRIBE_TRACKS for every track",
ControlMessage::SubscribeTracks(NamespaceRequest {
request_id: 10,
prefix: TrackNamespace(vec![]),
params: sorted(vec![
Parameter::Forward(false),
Parameter::GroupOrder(GroupOrder::Ascending),
rf(param::TRACK_PROPERTY_FILTER, 0, Some(prop::DYNAMIC_GROUPS), &[(1, Some(1))]),
Parameter::IncludeProperties(false),
]),
}),
);
// ── §9.16, §9.17, §9.19 ──
c.control("NAMESPACE", ControlMessage::Namespace(ns(&["42"])));
c.control("NAMESPACE_DONE", ControlMessage::NamespaceDone(ns(&["123"])));
c.control("PUBLISH_SKIPPED", ControlMessage::PublishSkipped(PublishSkipped { suffix: ns(&["cam2"]), name: name("video") }));
// ── §9.5 REQUEST_UPDATE ──
c.control(
"REQUEST_UPDATE for a subscription",
ControlMessage::RequestUpdate(RequestUpdate {
request_id: 12,
params: sorted(vec![
Parameter::Forward(false),
Parameter::SubscriberPriority(5),
Parameter::LocationFilter(LocationFilter::None),
Parameter::RangeFilter { ty: param::OBJECTID_FILTER, filter: None },
Parameter::NewGroupRequest(12),
]),
}),
);
c.control(
"REQUEST_UPDATE for a namespace subscription",
ControlMessage::RequestUpdate(RequestUpdate { request_id: 14, params: sorted(vec![Parameter::TrackNamespacePrefix(ns(&["example", "b"]))]) }),
);
// ── §9.3 REQUEST_OK, §9.4 REQUEST_ERROR ──
c.control(
"REQUEST_OK answering TRACK_STATUS",
ControlMessage::RequestOk(RequestOk { params: sorted(vec![Parameter::LargestObject(Location::new(3, 9))]), track_properties: props(vec![kv(prop::DEFAULT_PUBLISHER_GROUP_ORDER, 2)]) }),
);
c.control("REQUEST_OK answering PUBLISH", ControlMessage::RequestOk(RequestOk { params: sorted(vec![Parameter::Expires(1000)]), track_properties: Properties::new() }));
c.control(
"REQUEST_ERROR",
ControlMessage::RequestError(RequestError { code: RequestErrorCode::DOES_NOT_EXIST, retry_interval: 0, reason: b"no such track".to_vec(), redirect: None }),
);
c.control(
"REQUEST_ERROR with a redirect",
ControlMessage::RequestError(RequestError {
code: RequestErrorCode::REDIRECT,
retry_interval: 1,
reason: vec![],
redirect: Some(Redirect { connect_uri: b"moqt://other.example/live".to_vec(), namespace: ns(&["example"]), name: name("cam1") }),
}),
);
c.control(
"REQUEST_ERROR with a code this draft does not register",
ControlMessage::RequestError(RequestError { code: RequestErrorCode(0x9d), retry_interval: 30_001, reason: b"grease".to_vec(), redirect: None }),
);
// ── §6.3 a control stream: SETUP, then GOAWAY ──
let setup = Setup { options: { let mut o = SetupOptions::new(); o.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0")); o } };
let goaway = ControlMessage::Goaway(Goaway { new_session_uri: vec![], timeout_ms: 1000 });
c.stream(
"control stream: SETUP then GOAWAY",
encode_control_stream(&setup, std::slice::from_ref(&goaway)).unwrap(),
GroupOrder::Ascending,
vec![UniEvent::Header(StreamHeader::Control), UniEvent::Control(ControlMessage::Setup(setup.clone())), UniEvent::Control(goaway.clone())],
);
// ── §11.3 subgroup streams ──
let sg = |h: SubgroupHeader, objs: Vec<SubgroupObject>| -> (Vec<u8>, Vec<UniEvent>) {
let b = encode_subgroup_stream(&h, &objs).unwrap();
let mut ev = vec![UniEvent::Header(StreamHeader::Subgroup(h))];
ev.extend(objs.into_iter().map(UniEvent::SubgroupObject));
(b, ev)
};
let (b, ev) = sg(
SubgroupHeader { track_alias: 2, group_id: 0, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true },
vec![
SubgroupObject::normal(0, b"key".to_vec()),
SubgroupObject::normal(1, b"delta".to_vec()),
SubgroupObject { object_id: 2, properties: Properties::new(), status: ObjectStatus::EndOfGroup, payload: vec![] },
],
);
c.stream("subgroup: Subgroup ID 0, a priority, End of Group", b, GroupOrder::Ascending, ev);
let wai = wai_receipt(0x11, 0, "", vec![], 0x22);
let (b, ev) = sg(
SubgroupHeader { track_alias: 9, group_id: 7, subgroup_id: SubgroupId::FirstObjectId, publisher_priority: None, properties: true, end_of_group: false, first_object: false },
vec![
SubgroupObject { object_id: 5, properties: props(vec![kv(prop::PRIOR_OBJECT_ID_GAP, 2), kv(prop::OBJECT_DELIVERY_TIMEOUT, 40)]), status: ObjectStatus::Normal, payload: b"five".to_vec() },
SubgroupObject { object_id: 6, properties: Properties::new(), status: ObjectStatus::Normal, payload: vec![] },
SubgroupObject {
object_id: 8,
properties: with_immutable(vec![], vec![kv(0x3a40, 1), kb(0x3a41, &wai.to_bytes()), kv(0x3a42, 3), kv(0x3a44, 1)]),
status: ObjectStatus::Normal,
payload: b"WAI1".to_vec(),
},
],
);
c.stream("subgroup: Subgroup ID from the first object, default priority, properties", b, GroupOrder::Ascending, ev);
let (b, ev) = sg(
SubgroupHeader { track_alias: 1, group_id: 300, subgroup_id: SubgroupId::Explicit(3), publisher_priority: Some(0), properties: false, end_of_group: false, first_object: true },
vec![SubgroupObject::normal(0, b"x".to_vec()), SubgroupObject { object_id: 1, properties: Properties::new(), status: ObjectStatus::EndOfTrack, payload: vec![] }],
);
c.stream("subgroup: an explicit Subgroup ID, End of Track", b, GroupOrder::Ascending, ev);
let h = SubgroupHeader { track_alias: 4, group_id: 1, subgroup_id: SubgroupId::Zero, publisher_priority: Some(9), properties: false, end_of_group: false, first_object: true };
let mut hb = Vec::new();
h.encode(&mut hb);
c.stream("subgroup: a header and no object", hb, GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Subgroup(h))]);
// ── §11.4 fetch streams ──
let fo = |g: u64, o: u64, s: Option<u64>, p: u8, pr: Option<Properties>, pl: &[u8]| FetchEntry::Object(FetchObject { group_id: g, object_id: o, subgroup_id: s, publisher_priority: p, properties: pr, payload: pl.to_vec() });
for (order, label, gs) in [(GroupOrder::Ascending, "Ascending", [1u64, 4, 6, 9]), (GroupOrder::Descending, "Descending", [9, 6, 4, 1])] {
let entries = vec![
fo(gs[0], 0, Some(0), 7, None, b"a"),
fo(gs[0], 1, Some(0), 7, None, b"b"),
fo(gs[0], 5, Some(1), 9, Some(props(vec![kv(prop::PRIOR_OBJECT_ID_GAP, 3)])), b""),
FetchEntry::EndOfRange { kind: RangeEnd::NonExistent, location: Location::new(gs[1], 3) },
fo(gs[1], 4, None, 9, None, b"d"),
FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(gs[2], 0) },
FetchEntry::EndOfRange { kind: RangeEnd::TimedOut, location: Location::new(gs[3], 2) },
fo(gs[3], 3, Some(7), 9, None, b"e"),
fo(gs[3], 4, Some(8), 2, None, b"f"),
];
let b = encode_fetch_stream(&FetchHeader { request_id: 4 }, order, &entries).unwrap();
let mut ev = vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 4 }))];
ev.extend(entries.into_iter().map(UniEvent::FetchEntry));
c.stream(&format!("fetch in {label} order, with each End of Range"), b, order, ev);
}
let first_eor = vec![FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(2, 5) }, fo(2, 6, Some(0), 1, None, b"z")];
let b = encode_fetch_stream(&FetchHeader { request_id: 2 }, GroupOrder::Ascending, &first_eor).unwrap();
let mut ev = vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 2 }))];
ev.extend(first_eor.into_iter().map(UniEvent::FetchEntry));
c.stream("fetch beginning with an End of Range", b, GroupOrder::Ascending, ev);
let mut fb = Vec::new();
FetchHeader { request_id: 10 }.encode(&mut fb);
c.stream("fetch with no objects", fb, GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 10 }))]);
// ── §11.5 padding ──
c.stream("padding stream", encode_padding_stream(8), GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Padding), UniEvent::Padding(8)]);
// ── §11.2 datagrams ──
let dg = |d: ObjectDatagram| (Datagram::Object(d.clone()), d.encode().unwrap());
for (label, d) in [
("datagram: Object ID 0, default priority", ObjectDatagram { track_alias: 2, group_id: 5, object_id: 0, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(b"opus".to_vec()) }),
(
"datagram: a priority, properties, End of Group",
ObjectDatagram { track_alias: 2, group_id: 5, object_id: 9, publisher_priority: Some(3), properties: Some(props(vec![kv(prop::OBJECT_DELIVERY_TIMEOUT, 20)])), end_of_group: true, content: DatagramContent::Payload(b"last".to_vec()) },
),
("datagram: End of Track status", ObjectDatagram { track_alias: 2, group_id: 6, object_id: 0, publisher_priority: Some(3), properties: None, end_of_group: false, content: DatagramContent::Status(ObjectStatus::EndOfTrack) }),
(
"datagram: Normal status with properties",
ObjectDatagram { track_alias: 3, group_id: 1, object_id: 1, publisher_priority: None, properties: Some(props(vec![kb(0x3a45, b"p")])), end_of_group: false, content: DatagramContent::Status(ObjectStatus::Normal) },
),
("datagram: an empty payload", ObjectDatagram { track_alias: 3, group_id: 1, object_id: 2, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(vec![]) }),
] {
let (d, b) = dg(d);
c.datagram(label, d, b, true);
}
// Object ID 0 written out rather than flagged: the same object.
let d = ObjectDatagram { track_alias: 2, group_id: 5, object_id: 0, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(b"x".to_vec()) };
c.datagram("datagram: Object ID 0 written out", Datagram::Object(d), cat(&[&v(0x08), &v(2), &v(5), &v(0), b"x"]), false);
c.datagram("padding datagram", Datagram::Padding(4), Datagram::padding(4), true);
// ── the WAI_RECEIPT property's value (carriage) ──
let mut on_chip = 10_000u32.to_be_bytes().to_vec();
on_chip.extend_from_slice(&1_000_000u64.to_be_bytes());
on_chip.push(1);
let mut calibrated = 2_000u32.to_be_bytes().to_vec();
calibrated.extend_from_slice(&250_000u64.to_be_bytes());
calibrated.extend_from_slice(&(b"cal-2026-07".len() as u32).to_be_bytes());
calibrated.extend_from_slice(b"cal-2026-07");
for (label, p) in [
("WAI_RECEIPT: unmetered", wai_receipt(0x11, 0, "", vec![], 0x22)),
("WAI_RECEIPT: OnChipCounter", wai_receipt(0x33, 1_200_000, "OnChipCounter", on_chip, 0x44)),
("WAI_RECEIPT: CalibratedInstrument", wai_receipt(0x55, 4_000_000, "CalibratedInstrument", calibrated, 0x66)),
("WAI_RECEIPT: ModelBased", wai_receipt(0x77, 300, "ModelBased", vec![], 0x88)),
] {
c.push(label, "wai-receipt", &p.to_bytes(), true, None, wai_json(&p));
}
let mut extra = wai_receipt(0x11, 0, "", vec![], 0x22).to_bytes();
extra.push(0);
c.bad("WAI_RECEIPT: a byte after the last field", "wai-receipt", &extra, None, "invalid-property", "property_invalid");
c.bad("WAI_RECEIPT: cut short", "wai-receipt", &wai_receipt(0x11, 0, "", vec![], 0x22).to_bytes()[..60], None, "invalid-property", "property_invalid");
// ── negative vectors: control messages ──
let good_sub = ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["a"]), name: name("b"), params: sorted(vec![Parameter::Forward(true)]) }).encode().unwrap();
c.bad("control: the Length runs past the bytes", "control", &good_sub[..good_sub.len() - 1], None, "truncated", PV);
// Length one short: the last parameter is cut inside the body.
let mut short = good_sub.clone();
short[2] -= 1;
short.pop();
c.bad("control: a body shorter than its fields", "control", &short, None, "truncated", PV);
let body = cat(&[&v(4), &v(0), &[0]]);
c.bad("control: a byte after the last field", "control", &frame(msg::REQUEST_UPDATE, &body), None, "trailing-bytes", PV);
c.bad("control: a varint that runs past its body", "control", &frame(msg::GOAWAY, &[0x00, 0xff, 0x01]), None, "truncated", PV);
c.bad("control: type 0x40, reserved (CLIENT_SETUP up to -10)", "control", &frame(0x40, &[]), None, "unknown-message-type", PV);
c.bad("control: type 0x21, reserved (SERVER_SETUP up to -16)", "control", &frame(0x21, &[]), None, "unknown-message-type", PV);
c.bad("control: an unknown type", "control", &frame(0x3f, &[]), None, "unknown-message-type", PV);
let sub_body = |params: &[u8], n: u64| cat(&[&v(0), &v(1), &v(1), b"a", &v(1), b"b", &v(n), params]);
c.bad("SUBSCRIBE: FORWARD twice", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x10, 1, 0x00, 0], 2)), None, "duplicate", PV);
c.bad("SUBSCRIBE: one OBJECTID_FILTER key twice", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x26, 2, 0, 1, 0x00, 2, 0, 4], 2)), None, "duplicate-filter", "request:INVALID_FILTER");
c.bad("SUBSCRIBE: an unknown parameter", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x05, 0], 1)), None, "unknown-parameter", PV);
c.bad("SUBSCRIBE: FILL_PARAMETERS holding FORWARD", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x23, 2, 0x10, 1], 1)), None, "parameter-not-allowed", PV);
c.bad("SUBSCRIBE: FORWARD of 2", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x10, 2], 1)), None, "invalid-value", PV);
c.bad("SUBSCRIBE: GROUP_ORDER of 0", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x22, 0], 1)), None, "invalid-value", PV);
c.bad("SUBSCRIBE: a Location Filter Type of 6", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x21, 6], 1)), None, "invalid-value", PV);
let mut eg = vec![0x21, 3];
eg.extend(v(u64::MAX));
eg.extend(v(0));
eg.extend(v(1));
c.bad("SUBSCRIBE: StartGroup + EndGroupDelta past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&eg, 1)), None, "overflow", PV);
let mut ov = vec![0x35, 1];
ov.extend(v(u64::MAX));
ov.push(1);
c.bad("SUBSCRIBE: a parameter type past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&ov, 2)), None, "type-overflow", PV);
c.bad("SUBSCRIBE: a PRIORITY_FILTER above 255", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x27, 4, 0, 0x81, 0x00, 0], 1)), None, "invalid-filter", "request:INVALID_FILTER");
c.bad("SUBSCRIBE: an OBJECT_PROPERTY_FILTER on an odd type", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x28, 3, 0, 0x0b, 0], 1)), None, "invalid-filter", "request:INVALID_FILTER");
let mut rd = vec![0x26];
let inner = cat(&[&[0], &v(5), &v(u64::MAX)]);
rd.extend(v(inner.len() as u64));
rd.extend(inner);
c.bad("SUBSCRIBE: a range end past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&rd, 1)), None, "invalid-filter", "request:INVALID_FILTER");
c.bad("SUBSCRIBE: a token of an unknown alias type", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x03, 2, 0x04, 0x00], 1)), None, "malformed-token", "session:KEY_VALUE_FORMATTING_ERROR");
c.bad("SUBSCRIBE_OK: a parameter it may not carry (FORWARD)", "control", &frame(msg::SUBSCRIBE_OK, &[7, 1, 0x10, 1]), None, "parameter-not-allowed", PV);
let mut many = v(33);
for _ in 0..33 {
many.extend(cat(&[&v(1), b"x"]));
}
c.bad("SUBSCRIBE_NAMESPACE: 33 namespace fields", "control", &frame(msg::SUBSCRIBE_NAMESPACE, &cat(&[&v(0), &many, &v(0)])), None, "too-many-namespace-fields", PV);
c.bad("SUBSCRIBE_NAMESPACE: an empty namespace field", "control", &frame(msg::SUBSCRIBE_NAMESPACE, &cat(&[&v(0), &v(1), &v(0), &v(0)])), None, "empty-namespace-field", PV);
let long_field = vec![b'n'; 4000];
let fb = cat(&[&v(0), &v(1), &v(4000), &long_field, &v(200)]);
c.bad("SUBSCRIBE: a full track name past 4,096 bytes", "control", &frame(msg::SUBSCRIBE, &fb), None, "length-exceeds-limit", PV);
let reason = vec![b'r'; 1025];
c.bad("REQUEST_ERROR: a reason phrase of 1,025 bytes", "control", &frame(msg::REQUEST_ERROR, &cat(&[&v(0), &v(0), &v(1025), &reason])), None, "length-exceeds-limit", PV);
c.bad("GOAWAY: a URI of 8,193 bytes", "control", &frame(msg::GOAWAY, &v(8193)), None, "length-exceeds-limit", PV);
c.bad("SUBSCRIBE_OK: a property value of 65,536 bytes", "control", &frame(msg::SUBSCRIBE_OK, &cat(&[&v(1), &v(0), &v(0x3a45), &v(0x1_0000)])), None, "length-exceeds-limit", PV);
c.bad("FETCH_OK: End Of Track of 2", "control", &frame(msg::FETCH_OK, &[2, 0, 0, 0]), None, "invalid-value", PV);
c.bad("REQUEST_ERROR: REDIRECT cut short", "control", &frame(msg::REQUEST_ERROR, &cat(&[&v(0x34), &v(0), &v(0), &v(0)])), None, "truncated", PV);
c.bad("SETUP: MAX_FILTER_RANGES twice", "control", &frame(msg::SETUP, &[0x06, 1, 0x00, 2]), None, "duplicate", PV);
let mut sd = v(u64::MAX);
sd.extend(v(0));
sd.extend(v(1));
sd.extend(v(0));
c.bad("SETUP: an option type past 2^64-1", "control", &frame(msg::SETUP, &sd), None, "type-overflow", PV);
c.bad("SETUP: a token that does not read", "control", &frame(msg::SETUP, &[0x03, 1, 0x07]), None, "malformed-token", "session:KEY_VALUE_FORMATTING_ERROR");
c.bad("SUBSCRIBE_OK: Immutable Properties inside Immutable Properties", "control", &frame(msg::SUBSCRIBE_OK, &[1, 0, 0x0b, 3, 0x0b, 1, 0x00]), None, "malformed-track", "malformed-track");
c.bad("SUBSCRIBE_OK: DYNAMIC_GROUPS of 2", "control", &frame(msg::SUBSCRIBE_OK, &[1, 0, 0x30, 2]), None, "invalid-value", PV);
// ── negative vectors: streams and datagrams ──
let asc = Some(json!({"group_order": 1}));
let setup_bytes = ControlMessage::Setup(setup.clone()).encode().unwrap();
let g = goaway.encode().unwrap();
c.bad("control stream: GOAWAY twice", "uni-stream", &cat(&[&setup_bytes, &g, &g]), asc.clone(), "unexpected-message", PV);
let sub_msg = ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["a"]), name: name("b"), params: Parameters::new() }).encode().unwrap();
c.bad("control stream: SUBSCRIBE after SETUP", "uni-stream", &cat(&[&setup_bytes, &sub_msg]), asc.clone(), "unexpected-message", PV);
c.bad("stream: type 0x06", "uni-stream", &[0x06, 0], asc.clone(), "unknown-stream-type", PV);
c.bad("stream: a subgroup type of 0x91, two bytes", "uni-stream", &[0x80, 0x91, 0, 0, 0], asc.clone(), "unknown-stream-type", PV);
c.bad("subgroup: SUBGROUP_ID_MODE 0b11", "uni-stream", &[0x16, 0, 0, 0, 0], asc.clone(), "invalid-flags", PV);
let sgh = |flags: u8| vec![flags, 1, 2, 7];
c.bad("subgroup: Object Status 0x1", "uni-stream", &cat(&[&sgh(0x10), &[0, 0, 1]]), asc.clone(), "unknown-object-status", PV);
c.bad("subgroup: properties on End of Group", "uni-stream", &cat(&[&sgh(0x11), &[0, 2, 0x02, 5, 0, 3]]), asc.clone(), "properties-on-non-normal", PV);
c.bad("subgroup: a FIN inside an object", "uni-stream", &cat(&[&sgh(0x10), &[0, 5, b'a', b'b']]), asc.clone(), "truncated", PV);
let mut big = sgh(0x10);
big.extend(v(u64::MAX));
big.extend(&[1, b'a', 0, 1, b'b']);
c.bad("subgroup: an Object ID past 2^64-1", "uni-stream", &big, asc.clone(), "overflow", PV);
c.bad("subgroup: Immutable Properties inside Immutable Properties", "uni-stream", &cat(&[&sgh(0x11), &[0, 5, 0x0b, 3, 0x0b, 1, 0x00, 1, b'x']]), asc.clone(), "malformed-track", "malformed-track");
let mut mand = sgh(0x11);
mand.extend(&[0]);
let mp = cat(&[&v(0x4000), &v(1)]);
mand.extend(v(mp.len() as u64));
mand.extend(mp);
mand.extend(&[1, b'x']);
c.bad("subgroup: a Mandatory Track Property on an object", "uni-stream", &mand, asc.clone(), "malformed-track", "malformed-track");
c.bad("subgroup: a Prior Object ID Gap past the Object ID", "uni-stream", &cat(&[&sgh(0x11), &[1, 2, 0x3e, 3, 1, b'x']]), asc.clone(), "malformed-track", "malformed-track");
let mut fh = Vec::new();
FetchHeader { request_id: 0 }.encode(&mut fh);
c.bad("fetch: a first object that refers back", "uni-stream", &cat(&[&fh, &[0x1d, 0, 0, 7, 1, b'a']]), asc.clone(), "no-prior-object", PV);
c.bad("fetch: Serialization Flags of 0x80", "uni-stream", &cat(&[&fh, &v(0x80), &[0, 0]]), asc.clone(), "invalid-flags", PV);
c.bad("fetch: a Group ID below 0 in Descending order", "uni-stream", &cat(&[&fh, &[0x1c, 1, 0, 7, 1, b'a', 0x08, 1, 0, 1, b'b']]), Some(json!({"group_order": 2})), "overflow", PV);
c.bad("fetch: a FIN inside an object", "uni-stream", &cat(&[&fh, &[0x1c, 1, 0, 7, 3, b'a']]), asc.clone(), "truncated", PV);
c.bad("datagram: STATUS and END_OF_GROUP", "datagram", &[0x22, 1, 2, 3, 0], None, "invalid-flags", PV);
c.bad("datagram: bit 4 set", "datagram", &[0x10, 1, 2, 3, b'x'], None, "invalid-flags", PV);
c.bad("datagram: an undefined bit (0x40)", "datagram", &[0x40, 1, 2, 3, b'x'], None, "invalid-flags", PV);
c.bad("datagram: PROPERTIES with a length of 0", "datagram", &[0x0d, 1, 2, 0, b'x'], None, "empty-datagram-properties", PV);
c.bad("datagram: properties beside End of Track", "datagram", &[0x2d, 1, 2, 2, 0x02, 5, 4], None, "properties-on-non-normal", PV);
c.bad("datagram: an unknown status", "datagram", &[0x2c, 1, 2, 2], None, "unknown-object-status", PV);
c.bad("datagram: a byte after the status", "datagram", &[0x2c, 1, 2, 0, 0], None, "trailing-bytes", PV);
c.bad("datagram: cut short", "datagram", &[0x00, 1], None, "truncated", PV);
c.vectors
}
fn corpus_doc(vectors: Vec<Value>) -> Value {
json!({
"about": "draft-ietf-moq-transport-22 wire vectors: varints, control messages, unidirectional streams (control, subgroup, fetch, padding), datagrams, and the value of the WAI_RECEIPT object property of this crate's carriage. Each vector gives its bytes in hex and either its reading, in the JSON view of joule_moq::draft22::json described in the crate README, or the refusal it must meet with the draft's consequence. A uni-stream vector is the stream's bytes up to its FIN; its context gives the Group Order of the request a fetch stream answers (1 Ascending, 2 Descending). A vector marked canonical is the form this crate's encoder makes. Generated by tests/draft22_vectors.rs; regenerate with JOULE_MOQ_WRITE_VECTORS=1.",
"draft": "draft-ietf-moq-transport-22",
"vectors": vectors,
})
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn order_of(v: &Value) -> GroupOrder {
match v["context"]["group_order"].as_u64() {
Some(2) => GroupOrder::Descending,
_ => GroupOrder::Ascending,
}
}
/// Decode a vector: its reading, or its refusal.
fn decode(v: &Value) -> Result<(Value, Option<Vec<u8>>), Value> {
let b = unhex(v["hex"].as_str().unwrap());
let err = |e: WireError| json::error(&e);
match v["kind"].as_str().unwrap() {
"varint" => {
let (val, n) = varint::get(&b, "varint").map_err(err)?;
if n != b.len() {
return Err(json!({"error": "trailing-bytes"}));
}
Ok((json!({"value": val, "length": n}), Some(varint::to_vec(val))))
}
"control" => {
let m = ControlMessage::decode_exact(&b).map_err(err)?;
Ok((json::control(&m), Some(m.encode().unwrap())))
}
"uni-stream" => {
let order = order_of(v);
let ev = decode_uni_stream(&b, order).map_err(err)?;
Ok((Value::Array(ev.iter().map(json::uni_event).collect()), reencode(&ev, order)))
}
"datagram" => {
let d = Datagram::decode(&b).map_err(err)?;
let again = match &d {
Datagram::Object(o) => o.encode().unwrap(),
Datagram::Padding(n) => Datagram::padding(*n),
};
Ok((json::datagram(&d), Some(again)))
}
"wai-receipt" => match WaiReceiptProperty::from_bytes(&b) {
Ok(p) => Ok((wai_json(&p), Some(p.to_bytes()))),
Err(_) => Err(json!({"error": "invalid-property", "consequence": "property_invalid"})),
},
k => panic!("unknown kind {k}"),
}
}
/// The bytes this crate's encoder makes of a decoded stream.
fn reencode(ev: &[UniEvent], order: GroupOrder) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut sub: Option<SubgroupCodec> = None;
let mut fetch: Option<FetchCodec> = None;
for e in ev {
match e {
UniEvent::Header(StreamHeader::Control) => {}
UniEvent::Header(StreamHeader::Padding) => out.extend(varint::to_vec(joule_moq::draft22::data::stream_type::PADDING)),
UniEvent::Header(StreamHeader::Subgroup(h)) => {
h.encode(&mut out);
sub = Some(SubgroupCodec::new(*h));
}
UniEvent::Header(StreamHeader::Fetch(h)) => {
h.encode(&mut out);
fetch = Some(FetchCodec::new(order));
}
UniEvent::Control(m) => out.extend(m.encode().ok()?),
UniEvent::SubgroupObject(o) => sub.as_mut()?.encode_object(o, &mut out).ok()?,
UniEvent::FetchEntry(f) => fetch.as_mut()?.encode(f, &mut out).ok()?,
UniEvent::Padding(n) => out.resize(out.len() + n, 0),
}
}
Some(out)
}
/// The incremental decoder over the same bytes, fed in pieces of `step`.
fn decode_incrementally(b: &[u8], order: GroupOrder, step: usize) -> Result<Vec<UniEvent>, WireError> {
let mut d = UniStreamDecoder::new(order, Limits { max_payload: u64::MAX, max_properties: u64::MAX });
let mut out = Vec::new();
for chunk in b.chunks(step.max(1)) {
d.push(chunk);
while let Some(e) = d.next()? {
out.push(e);
}
}
d.finish()?;
Ok(out)
}
#[test]
fn the_draft22_corpus_is_current_and_holds() {
let doc = corpus_doc(build());
let text = serde_json::to_string_pretty(&doc).unwrap() + "\n";
if std::env::var("JOULE_MOQ_WRITE_VECTORS").as_deref() == Ok("1") {
std::fs::create_dir_all(std::path::Path::new(CORPUS).parent().unwrap()).unwrap();
std::fs::write(CORPUS, &text).unwrap();
}
let committed = std::fs::read_to_string(CORPUS).unwrap_or_else(|e| panic!("read {CORPUS}: {e}"));
assert!(committed == text, "{CORPUS} is not what tests/draft22_vectors.rs generates; regenerate it with JOULE_MOQ_WRITE_VECTORS=1");
let mut failures = Vec::new();
let (mut good, mut bad) = (0, 0);
for v in doc["vectors"].as_array().unwrap() {
let name = v["name"].as_str().unwrap();
let expect = &v["expect"];
let b = unhex(v["hex"].as_str().unwrap());
match (expect.get("error"), decode(v)) {
(None, Ok((got, again))) => {
good += 1;
if &got != expect {
failures.push(format!("{name}: read as {got}"));
}
if v["canonical"] == true && again.as_deref() != Some(&b[..]) {
failures.push(format!("{name}: re-encodes to other bytes"));
}
}
(None, Err(e)) => failures.push(format!("{name}: refused ({e}); the corpus reads it")),
(Some(_), Ok((got, _))) => failures.push(format!("{name}: read as {got}; the corpus refuses it")),
(Some(_), Err(e)) => {
bad += 1;
if &e != expect {
failures.push(format!("{name}: refused as {e}, the corpus says {expect}"));
}
}
}
// The incremental decoder agrees on every stream: the same events for a good
// one, a refusal for a negative one, whatever the pieces.
if v["kind"] == "uni-stream" {
for step in [1, 2, 7, b.len().max(1)] {
let inc = decode_incrementally(&b, order_of(v), step);
match (expect.get("error"), inc) {
(None, Ok(ev)) => {
// Padding arrives in whatever pieces the bytes did; count it whole.
let mut merged: Vec<UniEvent> = Vec::new();
for e in ev {
match (merged.last_mut(), e) {
(Some(UniEvent::Padding(n)), UniEvent::Padding(k)) => *n += k,
(_, e) => merged.push(e),
}
}
let got = Value::Array(merged.iter().map(json::uni_event).collect());
if &got != expect {
failures.push(format!("{name}: in pieces of {step}, read as {got}"));
}
}
(None, Err(e)) => failures.push(format!("{name}: in pieces of {step}, refused ({e})")),
(Some(_), Ok(_)) => failures.push(format!("{name}: in pieces of {step}, accepted")),
(Some(_), Err(_)) => {}
}
}
}
}
assert!(failures.is_empty(), "{} vector(s) disagree:\n{}", failures.len(), failures.join("\n"));
assert!(good > 0 && bad > 0, "the corpus holds both kinds");
}

View file

@ -0,0 +1,763 @@
#!/usr/bin/env python3
"""moqt22_check.py: an independent decoder of draft-ietf-moq-transport-22, run over
the vector corpus beside it (vectors.json).
Written from the draft's text, section by section, and not from the Rust codec it
checks. For every vector it decodes the bytes itself and compares:
- a good vector: its reading, in the corpus's JSON view (the crate README
describes it), must equal the vector's stated reading;
- a negative vector: the decoder must refuse it, with the stated kind of
refusal and the draft's consequence.
Where -22's text leaves an encoding open this decoder takes the readings the
crate README lists: FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04) are varints;
FILL_PARAMETERS holds parameters up to its length, with no count; an End of
Range's Group ID and Object ID are the delta fields its flag bits 0x08 and 0x04
mark; an unknown message, stream or datagram type closes the session with
PROTOCOL_VIOLATION.
python3 -I moqt22_check.py [vectors.json]
Exit 0 when every vector agrees, 1 otherwise (and when the corpus is empty).
Standard library only.
"""
import json
import os
import sys
U64 = (1 << 64) - 1
PV = "session:PROTOCOL_VIOLATION"
KVF = "session:KEY_VALUE_FORMATTING_ERROR"
INVALID_FILTER = "request:INVALID_FILTER"
MALFORMED = "malformed-track"
class Refused(Exception):
def __init__(self, kind, consequence=PV):
super().__init__(f"{kind} ({consequence})")
self.kind = kind
self.consequence = consequence
class Buf:
"""A cursor that refuses to read past its end (§8: every field is bounded)."""
def __init__(self, b):
self.b = bytes(b)
self.i = 0
def left(self):
return len(self.b) - self.i
def empty(self):
return self.left() == 0
def vi(self):
"""§8.1: the count of leading 1 bits of the first byte gives the length,
1 to 9 bytes; the bits after the first 0 and the bytes after it are the
value, big-endian. Longer forms than needed are valid."""
if self.empty():
raise Refused("truncated")
first = self.b[self.i]
ones = 0
while ones < 8 and first & (0x80 >> ones):
ones += 1
length = 9 if ones == 8 else ones + 1
if self.left() < length:
raise Refused("truncated")
if length == 9:
v = int.from_bytes(self.b[self.i + 1:self.i + 9], "big")
else:
v = first & (0xFF >> length)
for k in range(1, length):
v = (v << 8) | self.b[self.i + k]
self.i += length
return v
def u8(self):
if self.empty():
raise Refused("truncated")
v = self.b[self.i]
self.i += 1
return v
def u16(self):
return (self.u8() << 8) | self.u8()
def take(self, n):
if n > self.left():
raise Refused("truncated")
s = self.b[self.i:self.i + n]
self.i += n
return s
def rest(self):
return self.take(self.left())
def lp(self, limit=None):
"""A varint length, then that many bytes; a length past `limit` is refused
before any byte behind it is read."""
n = self.vi()
if limit is not None and n > limit:
raise Refused("length-exceeds-limit")
return self.take(n)
def done(self):
if not self.empty():
raise Refused("trailing-bytes")
def hx(b):
return bytes(b).hex()
# §8.3 Key-Value-Pairs ---------------------------------------------------------
def kvps(b):
"""Pairs until `b` ends: Delta Type; for an odd type a Length of at most
2^16-1 and that many bytes, for an even type one varint."""
r = Buf(b)
out = []
prev = 0
while not r.empty():
t = prev + r.vi()
if t > U64:
raise Refused("type-overflow")
prev = t
if t % 2:
out.append({"type": t, "bytes": hx(r.lp(0xFFFF))})
else:
out.append({"type": t, "value": r.vi()})
return out
IMMUTABLE = 0x0B
def immutable_of(pairs):
"""§10.7: the contents of Immutable Properties; a list that cannot be parsed
makes the track malformed."""
for p in pairs:
if p["type"] == IMMUTABLE and "bytes" in p:
try:
return kvps(bytes.fromhex(p["bytes"]))
except Refused:
raise Refused("malformed-track", MALFORMED)
return None
def flattened(pairs):
inner = immutable_of(pairs) or []
return [p for p in pairs if p["type"] != IMMUTABLE] + inner
def check_object_properties(pairs, group, obj):
"""§3.7, §10.7-10.9: conditions that make a track malformed, read on one object."""
if sum(1 for p in pairs if p["type"] == IMMUTABLE) > 1:
raise Refused("malformed-track", MALFORMED)
inner = immutable_of(pairs)
if inner is not None and any(p["type"] == IMMUTABLE for p in inner):
raise Refused("malformed-track", MALFORMED)
allp = flattened(pairs)
if any(0x4000 <= p["type"] <= 0x7FFF for p in allp):
raise Refused("malformed-track", MALFORMED)
for t, ident in ((0x3C, group), (0x3E, obj)):
gaps = [p["value"] for p in allp if p["type"] == t and "value" in p]
if len(gaps) > 1 or (gaps and gaps[0] > ident):
raise Refused("malformed-track", MALFORMED)
def check_track_properties(pairs):
"""§10.4-10.7 on Track Properties."""
inner = immutable_of(pairs)
if inner is not None and any(p["type"] == IMMUTABLE for p in inner):
raise Refused("malformed-track", MALFORMED)
for p in flattened(pairs):
v = p.get("value")
if v is None:
continue
if (p["type"] == 0x0E and v > 255) or (p["type"] == 0x22 and v not in (1, 2)) or (p["type"] == 0x30 and v > 1):
raise Refused("invalid-value")
# §8.7 Track Namespaces and names ----------------------------------------------
def namespace(r):
n = r.vi()
if n > 32:
raise Refused("too-many-namespace-fields")
fields = []
total = 0
for _ in range(n):
ln = r.vi()
if ln == 0:
raise Refused("empty-namespace-field")
total += ln
if total > 4096:
raise Refused("length-exceeds-limit")
fields.append(r.take(ln))
return fields
def track_name(r, ns_fields):
ln = r.vi()
if sum(len(f) for f in ns_fields) + ln > 4096:
raise Refused("length-exceeds-limit")
return r.take(ln)
def ns_json(fields):
return [hx(f) for f in fields]
# §8.9 Authorization Token ------------------------------------------------------
def token(b):
try:
r = Buf(b)
at = r.vi()
if at == 0:
t = {"alias_type": 0, "alias": r.vi()}
elif at == 1:
t = {"alias_type": 1, "alias": r.vi(), "token_type": r.vi()}
t["value"] = hx(r.rest())
elif at == 2:
t = {"alias_type": 2, "alias": r.vi()}
elif at == 3:
t = {"alias_type": 3, "token_type": r.vi()}
t["value"] = hx(r.rest())
else:
raise Refused("x")
r.done()
return t
except Refused:
raise Refused("malformed-token", KVF)
# §9.20 Message Parameters ------------------------------------------------------
NAMES = {
0x02: "OBJECT_DELIVERY_TIMEOUT", 0x03: "AUTHORIZATION_TOKEN", 0x04: "RENDEZVOUS_TIMEOUT",
0x06: "SUBGROUP_DELIVERY_TIMEOUT", 0x08: "EXPIRES", 0x09: "LARGEST_OBJECT", 0x0A: "FILL_TIMEOUT",
0x10: "FORWARD", 0x20: "SUBSCRIBER_PRIORITY", 0x21: "LOCATION_FILTER", 0x22: "GROUP_ORDER",
0x23: "FILL_PARAMETERS", 0x25: "SUBGROUP_FILTER", 0x26: "OBJECTID_FILTER", 0x27: "PRIORITY_FILTER",
0x28: "OBJECT_PROPERTY_FILTER", 0x29: "TRACK_PROPERTY_FILTER", 0x32: "NEW_GROUP_REQUEST",
0x34: "TRACK_NAMESPACE_PREFIX", 0x35: "INCLUDE_PROPERTIES",
}
# Which parameters each message lists (§9.3-9.18). REQUEST_UPDATE and REQUEST_OK
# are read before the request they concern is known: the union of their lists.
ALLOWED = {
"SUBSCRIBE": {0x02, 0x03, 0x04, 0x06, 0x10, 0x20, 0x21, 0x22, 0x23, 0x25, 0x26, 0x27, 0x28, 0x32, 0x35},
"SUBSCRIBE_OK": {0x08, 0x09},
"PUBLISH": {0x02, 0x03, 0x06, 0x08, 0x09, 0x10, 0x20, 0x21, 0x22},
"PUBLISH_STATE_NOTIFY": {0x09, 0x10, 0x21},
"FETCH": {0x03, 0x0A, 0x20, 0x21, 0x22, 0x25, 0x26, 0x27, 0x28, 0x35},
"FETCH_OK": set(),
"TRACK_STATUS": {0x03, 0x35},
"PUBLISH_NAMESPACE": {0x03},
"SUBSCRIBE_NAMESPACE": {0x03},
"SUBSCRIBE_TRACKS": {0x03, 0x10, 0x22, 0x25, 0x26, 0x27, 0x28, 0x29, 0x35},
"REQUEST_UPDATE": {0x02, 0x03, 0x06, 0x10, 0x20, 0x21, 0x23, 0x25, 0x26, 0x27, 0x28, 0x29, 0x32, 0x34},
"REQUEST_OK": {0x08, 0x09},
"FILL_PARAMETERS": {0x0A, 0x20, 0x21, 0x22, 0x25, 0x26, 0x27, 0x28},
}
def location_filter(r):
"""§9.20.9: the Location Filter Type says which fields follow."""
t = r.vi()
f = {"filter_type": t}
if t in (0, 5):
pass
elif t == 1:
f["start_group"] = r.vi()
elif t in (2, 3, 4):
f["start_group"] = r.vi()
f["start_object"] = r.vi()
if t >= 3:
f["end_group_delta"] = r.vi()
if t == 4:
f["end_object"] = r.vi()
if t >= 3 and f["start_group"] + f["end_group_delta"] > U64:
raise Refused("overflow")
else:
raise Refused("invalid-value")
return f
def range_filter(t, b):
"""§8.6 and §9.20.10-14. Length 0: no filter."""
if not b:
return None
r = Buf(b)
f = {"set_id": r.u8()}
if t in (0x28, 0x29):
pt = r.vi()
if pt % 2:
raise Refused("invalid-filter", INVALID_FILTER)
f["property_type"] = pt
ranges = []
prev_end = 0
while not r.empty():
start = prev_end + r.vi()
if start > U64:
raise Refused("invalid-filter", INVALID_FILTER)
end = None
if not r.empty():
end = start + r.vi()
if end > U64:
raise Refused("invalid-filter", INVALID_FILTER)
prev_end = end
ranges.append([start, end])
if t == 0x27 and any(s > 255 or (e is not None and e > 255) for s, e in ranges):
raise Refused("invalid-filter", INVALID_FILTER)
f["ranges"] = ranges
return f
def flag(r):
v = r.u8()
if v not in (0, 1):
raise Refused("invalid-value")
return v
def param_value(t, r):
name = NAMES[t]
p = {"type": name}
if t in (0x02, 0x04, 0x06, 0x08, 0x0A, 0x32):
p["value"] = r.vi()
elif t == 0x03:
p["token"] = token(r.lp())
elif t == 0x09:
p["location"] = [r.vi(), r.vi()]
elif t in (0x10, 0x35):
p["value"] = flag(r)
elif t == 0x20:
p["value"] = r.u8()
elif t == 0x21:
p["filter"] = location_filter(r)
elif t == 0x22:
v = r.u8()
if v not in (1, 2):
raise Refused("invalid-value")
p["value"] = v
elif t == 0x23:
p["parameters"] = parameters(Buf(r.lp()), "FILL_PARAMETERS", counted=False)
elif 0x25 <= t <= 0x29:
p["filter"] = range_filter(t, r.lp())
elif t == 0x34:
p["namespace"] = ns_json(namespace(r))
return p
def parameters(r, context, counted=True):
"""§9.20: Type Delta then a value whose form the type gives. Every parameter
must be one this draft defines and one the message lists; one that may not
repeat appears once, and a Range Filter's (type, SetID, property type) once."""
out = []
keys = set()
prev = 0
n = r.vi() if counted else None
i = 0
while (i < n) if counted else (not r.empty()):
i += 1
t = prev + r.vi()
if t > U64:
raise Refused("type-overflow")
prev = t
if t not in NAMES:
raise Refused("unknown-parameter")
if t not in ALLOWED[context]:
raise Refused("parameter-not-allowed")
p = param_value(t, r)
if 0x25 <= t <= 0x29:
f = p["filter"]
key = (t, None if f is None else f["set_id"], None if f is None else f.get("property_type"))
if key in keys:
raise Refused("duplicate-filter", INVALID_FILTER)
keys.add(key)
elif t != 0x03:
if t in keys:
raise Refused("duplicate")
keys.add(t)
out.append(p)
return out
# §9 control messages -----------------------------------------------------------
TYPES = {
0x02: "REQUEST_UPDATE", 0x03: "SUBSCRIBE", 0x04: "SUBSCRIBE_OK", 0x05: "REQUEST_ERROR",
0x06: "PUBLISH_NAMESPACE", 0x07: "REQUEST_OK", 0x08: "NAMESPACE", 0x0B: "PUBLISH_DONE",
0x0D: "TRACK_STATUS", 0x0E: "NAMESPACE_DONE", 0x0F: "PUBLISH_SKIPPED", 0x10: "GOAWAY",
0x16: "FETCH", 0x18: "FETCH_OK", 0x1D: "PUBLISH", 0x22: "PUBLISH_STATE_NOTIFY",
0x50: "SUBSCRIBE_NAMESPACE", 0x51: "SUBSCRIBE_TRACKS", 0x2F00: "SETUP",
}
KNOWN_OPTIONS = [0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
def track_props(r):
pairs = kvps(r.rest())
check_track_properties(pairs)
return pairs
def setup_body(r):
"""§9.1: Setup Options as Key-Value-Pairs over the whole body. A known option
other than AUTHORIZATION_TOKEN appears once; each token reads (§8.9)."""
opts = kvps(r.rest())
for k in KNOWN_OPTIONS:
if k != 0x03 and sum(1 for o in opts if o["type"] == k) > 1:
raise Refused("duplicate")
for o in opts:
if o["type"] == 0x03:
token(bytes.fromhex(o.get("bytes", "")))
return {"options": opts}
def body(name, r):
if name == "SETUP":
return setup_body(r)
if name == "GOAWAY":
uri = r.lp(8192)
return {"new_session_uri": hx(uri), "timeout_ms": r.vi()}
if name in ("SUBSCRIBE", "FETCH", "TRACK_STATUS"):
rid = r.vi()
ns = namespace(r)
nm = track_name(r, ns)
return {"request_id": rid, "namespace": ns_json(ns), "name": hx(nm), "parameters": parameters(r, name)}
if name == "SUBSCRIBE_OK":
alias = r.vi()
ps = parameters(r, name)
return {"track_alias": alias, "parameters": ps, "track_properties": track_props(r)}
if name == "PUBLISH_STATE_NOTIFY":
return {"parameters": parameters(r, name)}
if name == "PUBLISH":
rid = r.vi()
ns = namespace(r)
nm = track_name(r, ns)
alias = r.vi()
ps = parameters(r, name)
return {"request_id": rid, "namespace": ns_json(ns), "name": hx(nm), "track_alias": alias, "parameters": ps, "track_properties": track_props(r)}
if name == "PUBLISH_DONE":
return {"status": r.vi(), "stream_count": r.vi(), "reason": hx(r.lp(1024))}
if name == "FETCH_OK":
eot = r.u8()
if eot not in (0, 1):
raise Refused("invalid-value")
loc = [r.vi(), r.vi()]
ps = parameters(r, name)
return {"end_of_track": eot, "end_location": loc, "parameters": ps, "track_properties": track_props(r)}
if name in ("PUBLISH_NAMESPACE", "SUBSCRIBE_NAMESPACE", "SUBSCRIBE_TRACKS"):
rid = r.vi()
ns = namespace(r)
return {"request_id": rid, "prefix": ns_json(ns), "parameters": parameters(r, name)}
if name in ("NAMESPACE", "NAMESPACE_DONE"):
return {"suffix": ns_json(namespace(r))}
if name == "PUBLISH_SKIPPED":
ns = namespace(r)
return {"suffix": ns_json(ns), "name": hx(track_name(r, ns))}
if name == "REQUEST_UPDATE":
rid = r.vi()
return {"request_id": rid, "parameters": parameters(r, name)}
if name == "REQUEST_OK":
ps = parameters(r, name)
return {"parameters": ps, "track_properties": track_props(r)}
if name == "REQUEST_ERROR":
code = r.vi()
retry = r.vi()
reason = r.lp(1024)
redirect = None
if code == 0x34: # REDIRECT carries a Redirect (§9.4.1)
uri = r.lp()
ns = namespace(r)
redirect = {"connect_uri": hx(uri), "namespace": ns_json(ns), "name": hx(track_name(r, ns))}
return {"code": code, "retry_interval": retry, "reason": hx(reason), "redirect": redirect}
raise AssertionError(name)
def control_message(r):
"""§9: Type (vi64), Length (16), a body of exactly Length bytes."""
t = r.vi()
if t not in TYPES:
raise Refused("unknown-message-type")
ln = r.u16()
b = Buf(r.take(ln))
m = body(TYPES[t], b)
b.done()
m["message"] = TYPES[t]
return m
# §11 the data plane ------------------------------------------------------------
def subgroup_stream(t, r):
"""§11.3.1: SUBGROUP_HEADER, then objects to the FIN."""
mode = (t & 0x06) >> 1
if mode == 3:
raise Refused("invalid-flags")
h = {"header": "subgroup", "track_alias": r.vi(), "group_id": r.vi(), "subgroup_id_mode": mode, "subgroup_id": None}
if mode == 2:
h["subgroup_id"] = r.vi()
h["publisher_priority"] = None if t & 0x20 else r.u8()
h["properties"] = bool(t & 0x01)
h["end_of_group"] = bool(t & 0x08)
h["first_object"] = bool(t & 0x40)
events = [h]
prev = None
while not r.empty():
delta = r.vi()
oid = delta if prev is None else prev + delta + 1
if oid > U64:
raise Refused("overflow")
pairs = kvps(r.lp()) if h["properties"] else []
ln = r.vi()
status = 0
if ln == 0:
status = r.vi()
if status not in (0, 3, 4):
raise Refused("unknown-object-status")
if status != 0 and pairs:
raise Refused("properties-on-non-normal")
payload = r.take(ln)
check_object_properties(pairs, h["group_id"], oid)
prev = oid
events.append({"object": {"object_id": oid, "properties": pairs, "status": status, "payload": hx(payload)}})
return events
RANGE_ENDS = {0x8C: "non-existent", 0x10C: "unknown", 0x20C: "timed-out"}
def fetch_stream(r, order):
"""§11.4.1: FETCH_HEADER, then objects and End of Range indicators, each
referring to the one before it; Group IDs move in the request's Group Order."""
events = [{"header": "fetch", "request_id": r.vi()}]
prior = None # [group, object] of the prior entry
prior_sub = "none" # the last actual object's Subgroup ID; None for a datagram
prior_prio = None
def step(g, d):
if order == 1:
v = g + d + 1
if v > U64:
raise Refused("overflow")
else:
v = g - (d + 1)
if v < 0:
raise Refused("overflow")
return v
while not r.empty():
flags = r.vi()
if flags >= 0x80:
if flags not in RANGE_ENDS:
raise Refused("invalid-flags")
gd = r.vi()
od = r.vi()
g = gd if prior is None else step(prior[0], gd)
prior = [g, od]
events.append({"end_of_range": {"kind": RANGE_ENDS[flags], "location": [g, od]}})
continue
if flags & 0x08:
d = r.vi()
g = d if prior is None else step(prior[0], d)
else:
if prior is None:
raise Refused("no-prior-object")
g = prior[0]
if flags & 0x40:
sub = None
else:
mode = flags & 0x03
if mode == 0:
sub = 0
elif mode in (1, 2):
if prior_sub == "none" or prior_sub is None:
raise Refused("no-prior-object")
sub = prior_sub + (mode - 1)
if sub > U64:
raise Refused("overflow")
else:
sub = r.vi()
if flags & 0x04:
d = r.vi()
if flags & 0x08:
o = d
else:
if prior is None:
raise Refused("no-prior-object")
o = prior[1] + d
else:
if prior is None:
raise Refused("no-prior-object")
o = prior[1] + 1
if o > U64:
raise Refused("overflow")
if flags & 0x10:
prio = r.u8()
else:
if prior_prio is None:
raise Refused("no-prior-object")
prio = prior_prio
pairs = kvps(r.lp()) if flags & 0x20 else None
payload = r.take(r.vi())
if pairs is not None:
check_object_properties(pairs, g, o)
prior, prior_sub, prior_prio = [g, o], sub, prio
events.append({"fetch_object": {"group_id": g, "object_id": o, "subgroup_id": sub, "publisher_priority": prio, "properties": pairs, "payload": hx(payload)}})
return events
def control_stream(r):
"""§6.3: a control stream opens with SETUP (its type is the stream's); after
it only GOAWAY, once (§9.2, Table 5)."""
ln = r.u16()
b = Buf(r.take(ln))
setup = setup_body(b)
b.done()
setup["message"] = "SETUP"
events = [{"header": "control"}, {"control": setup}]
goaway = False
while not r.empty():
m = control_message(r)
if m["message"] != "GOAWAY" or goaway:
raise Refused("unexpected-message")
goaway = True
events.append({"control": m})
return events
def uni_stream(b, order):
"""§6.4.1, Table 2: the stream type picks the stream."""
r = Buf(b)
t = r.vi()
if t == 0x05:
return fetch_stream(r, order)
if t == 0x2F00:
return control_stream(r)
if t == 0x132B3E28:
n = len(r.rest())
return [{"header": "padding"}] + ([{"padding": n}] if n else [])
if t < 0x80 and t & 0x10:
return subgroup_stream(t, r)
raise Refused("unknown-stream-type")
def datagram(b):
"""§11.2.1 and §11.5.2."""
r = Buf(b)
t = r.vi()
if t == 0x132B3E29:
return {"padding": r.left()}
if t & ~0x2F or (t & 0x22) == 0x22:
raise Refused("invalid-flags")
d = {"track_alias": r.vi(), "group_id": r.vi()}
d["object_id"] = 0 if t & 0x04 else r.vi()
d["publisher_priority"] = None if t & 0x08 else r.u8()
pairs = None
if t & 0x01:
pb = r.lp()
if not pb:
raise Refused("empty-datagram-properties")
pairs = kvps(pb)
d["properties"] = pairs
d["end_of_group"] = bool(t & 0x02)
if t & 0x20:
s = r.vi()
if s not in (0, 3, 4):
raise Refused("unknown-object-status")
if s != 0 and pairs is not None:
raise Refused("properties-on-non-normal")
r.done()
d["status"], d["payload"] = s, None
else:
d["status"], d["payload"] = None, hx(r.rest())
if pairs is not None:
check_object_properties(pairs, d["group_id"], d["object_id"])
return {"datagram": d}
def varint_only(b):
r = Buf(b)
v = r.vi()
r.done()
return {"value": v, "length": len(b)}
def wai_receipt(b):
"""The WAI_RECEIPT property's value, as the crate README lays it out:
content_hash (32 bytes), joules_micro (a varint), acquisition (a varint length,
UTF-8), acquisition_evidence (a varint length, bytes), group_receipt (32 bytes),
nothing after."""
try:
r = Buf(b)
ch = r.take(32)
j = r.vi()
acq = r.lp().decode("utf-8")
ev = r.lp()
gr = r.take(32)
r.done()
except (Refused, UnicodeDecodeError):
raise Refused("invalid-property", "property_invalid")
return {"content_hash": hx(ch), "joules_micro": j, "acquisition": acq, "acquisition_evidence": hx(ev), "group_receipt": hx(gr)}
def decode(v):
b = bytes.fromhex(v["hex"])
kind = v["kind"]
if kind == "varint":
return varint_only(b)
if kind == "control":
r = Buf(b)
m = control_message(r)
r.done()
return m
if kind == "uni-stream":
return uni_stream(b, v.get("context", {}).get("group_order", 1))
if kind == "datagram":
return datagram(b)
if kind == "wai-receipt":
return wai_receipt(b)
raise AssertionError(f"unknown kind {kind}")
def main(argv):
path = argv[1] if len(argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "vectors.json")
with open(path, encoding="utf-8") as fh:
doc = json.load(fh)
vectors = doc.get("vectors", [])
if not vectors:
print(f"{path}: no vectors; refusing to pass on nothing")
return 1
failures = []
good = bad = 0
for v in vectors:
exp = v["expect"]
try:
got = decode(v)
if "error" in exp:
failures.append(f"{v['name']}: read as {json.dumps(got)}; the corpus refuses it ({exp['error']})")
elif got != exp:
failures.append(f"{v['name']}: read as {json.dumps(got, sort_keys=True)}\n the corpus says {json.dumps(exp, sort_keys=True)}")
else:
good += 1
except Refused as e:
if "error" not in exp:
failures.append(f"{v['name']}: refused ({e.kind}, {e.consequence}); the corpus reads it")
elif (e.kind, e.consequence) != (exp["error"], exp["consequence"]):
failures.append(f"{v['name']}: refused as {e.kind} ({e.consequence}); the corpus says {exp['error']} ({exp['consequence']})")
else:
bad += 1
for f in failures:
print(f)
print(f"{good} read as stated, {bad} refused as stated, {len(failures)} disagree")
return 1 if failures or not good or not bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

File diff suppressed because one or more lines are too long