From 0cea33f29b6ceab71ebb189d74dad6d938cbc2d1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 09:58:00 -0400 Subject: [PATCH 1/7] joule-moq: a draft-ietf-moq-transport-22 wire codec beside the draft-08-style one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codec of -22 as a module of its own (draft22), chosen by a Draft enum whose ALPN is moqt-22: the leading-ones varint of §8.1, Key-Value-Pairs, Properties and Setup Options, the Authorization Token, every control message of Table 5 with its parameters, request IDs and stream rules, and the data plane (subgroup streams, datagrams, fetch streams, padding). Decoders are bounded and refuse with a typed error carrying the draft's consequence. The crate does not use it yet. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq/src/draft.rs | 78 ++ .../crates/joule-moq/src/draft22/codes.rs | 163 +++ .../crates/joule-moq/src/draft22/control.rs | 586 +++++++++ .../crates/joule-moq/src/draft22/data.rs | 1155 +++++++++++++++++ .../crates/joule-moq/src/draft22/error.rs | 188 +++ .../crates/joule-moq/src/draft22/io.rs | 143 ++ .../crates/joule-moq/src/draft22/json.rs | 330 +++++ .../crates/joule-moq/src/draft22/kvp.rs | 409 ++++++ .../crates/joule-moq/src/draft22/mod.rs | 70 + .../crates/joule-moq/src/draft22/names.rs | 244 ++++ .../crates/joule-moq/src/draft22/params.rs | 832 ++++++++++++ .../crates/joule-moq/src/draft22/request.rs | 415 ++++++ .../crates/joule-moq/src/draft22/token.rs | 116 ++ .../crates/joule-moq/src/draft22/varint.rs | 161 +++ .../crates/joule-moq/src/error.rs | 3 + .../joule-code-rs/crates/joule-moq/src/lib.rs | 2 + 16 files changed, 4895 insertions(+) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft.rs new file mode 100644 index 000000000..84d6813bf --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft.rs @@ -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 { + Self::SUPPORTED.iter().copied().find(|d| d.alpn() == alpn) + } + + pub fn from_version_code(code: u32) -> Option { + 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) -> Option { + offered.into_iter().find_map(Draft::from_alpn) + } + + /// The ALPN list a client offers, most preferred first. + pub fn alpn_list() -> Vec> { + 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)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs new file mode 100644 index 000000000..1260309d1 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs @@ -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) % 0x7f == 0 +} + +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}"); + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs new file mode 100644 index 000000000..7cb96c177 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs @@ -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, + 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, +} + +/// 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, + 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, + /// Present exactly when `code` is REDIRECT. + pub redirect: Option, +} + +/// 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> { + Ok(r.length_prefixed(Some(MAX_REASON_PHRASE), "reason phrase")?.to_vec()) +} + +fn track_properties(r: &mut Reader<'_>) -> WireResult { + 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 { + 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 { + 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> { + 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) -> WireResult<()> { + let req = |r: &TrackRequest, c: &ParamContext, out: &mut Vec| -> 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| -> 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 { + 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 { + 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 { + let req = |r: &mut Reader<'_>, c: &ParamContext| -> WireResult { + 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 { + 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(()) + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs new file mode 100644 index 000000000..61a7bc670 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs @@ -0,0 +1,1155 @@ +//! The data plane (§6.4.1, §11): subgroup streams, object datagrams, fetch +//! streams and padding. +//! +//! Two decoders share one strict parser: +//! +//! - [`decode_uni_stream`] reads a whole unidirectional stream (its bytes up to +//! the FIN) field by field, in order, and refuses at the first fault; +//! - [`UniStreamDecoder`] takes bytes as they arrive and yields each object as +//! soon as all of it is there, so a live subgroup can be consumed before its +//! FIN. It waits for an object's last byte before parsing it, so where one +//! object holds two faults, one of them a FIN inside it, the two decoders can +//! name different faults; both refuse. +//! +//! A fetch object's Group ID Delta runs in the request's Group Order (§11.4.1), +//! which the FETCH (or the subscription's fill) set, not the stream: the fetch +//! parser takes it as an argument. + +use super::error::{WireError, WireResult}; +use super::io::{put_varint, Reader}; +use super::kvp::Properties; +use super::names::Location; +use super::params::GroupOrder; +use super::control::{msg, ControlMessage, Setup}; +use super::kvp::SetupOptions; +use super::request::ControlStreamState; +use super::varint; + +/// Unidirectional stream types (§6.4.1, Table 2). +pub mod stream_type { + pub const FETCH_HEADER: u64 = 0x05; + pub const SETUP: u64 = 0x2f00; + pub const PADDING: u64 = 0x132b_3e28; +} + +/// The type of a padding datagram (§11.5.2). +pub const PADDING_DATAGRAM: u64 = 0x132b_3e29; + +/// Object Status (§11.1.1, Table 17). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ObjectStatus { + Normal, + EndOfGroup, + EndOfTrack, +} + +impl ObjectStatus { + pub fn code(self) -> u64 { + match self { + ObjectStatus::Normal => 0x0, + ObjectStatus::EndOfGroup => 0x3, + ObjectStatus::EndOfTrack => 0x4, + } + } + pub fn from_code(v: u64) -> WireResult { + match v { + 0x0 => Ok(ObjectStatus::Normal), + 0x3 => Ok(ObjectStatus::EndOfGroup), + 0x4 => Ok(ObjectStatus::EndOfTrack), + other => Err(WireError::UnknownObjectStatus(other)), + } + } +} + +// ── Subgroup streams (§11.3) ───────────────────────────────────────────────── + +/// How a SUBGROUP_HEADER gives the Subgroup ID (SUBGROUP_ID_MODE, §11.3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SubgroupId { + /// 0b00: absent, and 0. + Zero, + /// 0b01: absent, and the Object ID of the stream's first object. + FirstObjectId, + /// 0b10: present. + Explicit(u64), +} + +/// SUBGROUP_HEADER (§11.3.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SubgroupHeader { + pub track_alias: u64, + pub group_id: u64, + pub subgroup_id: SubgroupId, + /// `None`: the DEFAULT_PRIORITY bit; the subgroup takes the priority the + /// subscription set. + pub publisher_priority: Option, + /// The PROPERTIES bit: every object of the stream carries a Properties field. + pub properties: bool, + /// The END_OF_GROUP bit: this subgroup holds the group's largest object. + pub end_of_group: bool, + /// The FIRST_OBJECT bit: the stream begins with the subgroup's first object. + pub first_object: bool, +} + +/// Flag bits of SUBGROUP_HEADER's Type Flags. +pub mod subgroup_flags { + pub const PROPERTIES: u64 = 0x01; + pub const SUBGROUP_ID_MODE: u64 = 0x06; + pub const END_OF_GROUP: u64 = 0x08; + pub const ALWAYS: u64 = 0x10; + pub const DEFAULT_PRIORITY: u64 = 0x20; + pub const FIRST_OBJECT: u64 = 0x40; +} + +impl SubgroupHeader { + /// Whether a stream type is in SUBGROUP_HEADER's space `0b0XX1XXXX`. + pub fn is_subgroup_type(t: u64) -> bool { + t < 0x80 && t & subgroup_flags::ALWAYS != 0 + } + + pub fn type_flags(&self) -> u64 { + use subgroup_flags::*; + let mode = match self.subgroup_id { + SubgroupId::Zero => 0, + SubgroupId::FirstObjectId => 1, + SubgroupId::Explicit(_) => 2, + }; + ALWAYS + | (self.properties as u64 * PROPERTIES) + | (mode << 1) + | (self.end_of_group as u64 * END_OF_GROUP) + | (self.publisher_priority.is_none() as u64 * DEFAULT_PRIORITY) + | (self.first_object as u64 * FIRST_OBJECT) + } + + /// The header with its type: the first bytes of the stream. + pub fn encode(&self, out: &mut Vec) { + put_varint(out, self.type_flags()); + put_varint(out, self.track_alias); + put_varint(out, self.group_id); + if let SubgroupId::Explicit(s) = self.subgroup_id { + put_varint(out, s); + } + if let Some(p) = self.publisher_priority { + out.push(p); + } + } + + /// The rest of the header after its Type Flags. + pub fn decode_after_type(flags: u64, r: &mut Reader<'_>) -> WireResult { + use subgroup_flags::*; + let bad = WireError::InvalidFlags { what: "SUBGROUP_HEADER", value: flags }; + if !Self::is_subgroup_type(flags) { + return Err(bad); + } + let mode = (flags & SUBGROUP_ID_MODE) >> 1; + if mode == 0b11 { + return Err(bad); + } + const W: &str = "SUBGROUP_HEADER"; + let track_alias = r.varint(W)?; + let group_id = r.varint(W)?; + let subgroup_id = match mode { + 0 => SubgroupId::Zero, + 1 => SubgroupId::FirstObjectId, + _ => SubgroupId::Explicit(r.varint(W)?), + }; + let publisher_priority = if flags & DEFAULT_PRIORITY != 0 { None } else { Some(r.u8(W)?) }; + Ok(SubgroupHeader { + track_alias, + group_id, + subgroup_id, + publisher_priority, + properties: flags & PROPERTIES != 0, + end_of_group: flags & END_OF_GROUP != 0, + first_object: flags & FIRST_OBJECT != 0, + }) + } + + /// The Subgroup ID, given the Object ID of the stream's first object. + pub fn resolve_subgroup_id(&self, first_object_id: u64) -> u64 { + match self.subgroup_id { + SubgroupId::Zero => 0, + SubgroupId::FirstObjectId => first_object_id, + SubgroupId::Explicit(s) => s, + } + } +} + +/// One object on a subgroup stream (§11.3.1, Figure 27). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SubgroupObject { + pub object_id: u64, + /// Present on the wire exactly when the header's PROPERTIES bit is set; + /// empty otherwise, and empty for a status other than Normal. + pub properties: Properties, + /// Sent only beside an empty payload; Normal beside any other. + pub status: ObjectStatus, + pub payload: Vec, +} + +impl SubgroupObject { + pub fn normal(object_id: u64, payload: impl Into>) -> Self { + SubgroupObject { object_id, properties: Properties::new(), status: ObjectStatus::Normal, payload: payload.into() } + } +} + +/// The objects of one subgroup stream, encoded and decoded in order: each +/// Object ID is a delta from the previous one on the stream (§11.3.1). +#[derive(Clone, Debug)] +pub struct SubgroupCodec { + pub header: SubgroupHeader, + prev: Option, +} + +impl SubgroupCodec { + pub fn new(header: SubgroupHeader) -> Self { + SubgroupCodec { header, prev: None } + } + + /// The Object ID of the previous object on the stream. + pub fn previous_object_id(&self) -> Option { + self.prev + } + + pub fn encode_object(&mut self, o: &SubgroupObject, out: &mut Vec) -> WireResult<()> { + let unenc = |reason| Err(WireError::Unencodable { what: "subgroup object", reason }); + let delta = match self.prev { + None => o.object_id, + Some(p) if o.object_id > p => o.object_id - p - 1, + Some(_) => return unenc("Object IDs ascend on a subgroup stream"), + }; + if !self.header.properties && !o.properties.is_empty() { + return unenc("properties on a stream whose header lacks the PROPERTIES bit"); + } + if o.status != ObjectStatus::Normal && (!o.properties.is_empty() || !o.payload.is_empty()) { + return unenc("a status other than Normal carries no properties and no payload"); + } + put_varint(out, delta); + if self.header.properties { + let p = o.properties.to_bytes()?; + put_varint(out, p.len() as u64); + out.extend_from_slice(&p); + } + put_varint(out, o.payload.len() as u64); + if o.payload.is_empty() { + put_varint(out, o.status.code()); + } + out.extend_from_slice(&o.payload); + self.prev = Some(o.object_id); + Ok(()) + } + + /// Read the next object. In order: the Object ID (refused past 2^64-1), the + /// Properties when the header has them, the payload length and, beside an + /// empty payload, the status; then a status other than Normal with + /// properties is refused; then the payload; then the object's properties are + /// checked (§10.7-10.9, a malformed track). + pub fn decode_object(&mut self, r: &mut Reader<'_>) -> WireResult { + const W: &str = "subgroup object"; + let delta = r.varint(W)?; + let object_id = match self.prev { + None => delta, + Some(p) => p.checked_add(delta).and_then(|v| v.checked_add(1)).ok_or(WireError::Overflow { what: "Object ID" })?, + }; + let properties = if self.header.properties { + let b = r.length_prefixed(None, "object properties")?; + Properties::decode(b, "object properties")? + } else { + Properties::new() + }; + let len = r.varint("object payload length")?; + let status = if len == 0 { ObjectStatus::from_code(r.varint("object status")?)? } else { ObjectStatus::Normal }; + if status != ObjectStatus::Normal && !properties.is_empty() { + return Err(WireError::PropertiesOnNonNormal); + } + let payload = r.bytes(len, "object payload")?.to_vec(); + properties.check_object(self.header.group_id, object_id)?; + self.prev = Some(object_id); + Ok(SubgroupObject { object_id, properties, status, payload }) + } +} + +// ── Datagrams (§11.2) ──────────────────────────────────────────────────────── + +/// What an OBJECT_DATAGRAM carries after its header. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum DatagramContent { + /// The STATUS bit clear: the rest of the datagram is the payload. + Payload(Vec), + /// The STATUS bit set: an Object Status and no payload. + Status(ObjectStatus), +} + +/// OBJECT_DATAGRAM (§11.2.1). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ObjectDatagram { + pub track_alias: u64, + pub group_id: u64, + /// Encoded with the ZERO_OBJECT_ID bit when 0. + pub object_id: u64, + /// `None`: the DEFAULT_PRIORITY bit. + pub publisher_priority: Option, + /// `Some` sets the PROPERTIES bit; the list is then non-empty. + pub properties: Option, + /// The END_OF_GROUP bit. + pub end_of_group: bool, + pub content: DatagramContent, +} + +/// Flag bits of OBJECT_DATAGRAM's Type Flags. +pub mod datagram_flags { + pub const PROPERTIES: u64 = 0x01; + pub const END_OF_GROUP: u64 = 0x02; + pub const ZERO_OBJECT_ID: u64 = 0x04; + pub const DEFAULT_PRIORITY: u64 = 0x08; + pub const RESERVED: u64 = 0x10; + pub const STATUS: u64 = 0x20; + /// Every bit with a meaning. + pub const DEFINED: u64 = PROPERTIES | END_OF_GROUP | ZERO_OBJECT_ID | DEFAULT_PRIORITY | STATUS; +} + +/// A datagram of either type. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Datagram { + Object(ObjectDatagram), + /// A padding datagram, its padding length. + Padding(usize), +} + +impl ObjectDatagram { + pub fn type_flags(&self) -> u64 { + use datagram_flags::*; + (self.properties.is_some() as u64 * PROPERTIES) + | (self.end_of_group as u64 * END_OF_GROUP) + | ((self.object_id == 0) as u64 * ZERO_OBJECT_ID) + | (self.publisher_priority.is_none() as u64 * DEFAULT_PRIORITY) + | (matches!(self.content, DatagramContent::Status(_)) as u64 * STATUS) + } + + pub fn encode(&self) -> WireResult> { + let unenc = |reason| Err(WireError::Unencodable { what: "OBJECT_DATAGRAM", reason }); + if self.end_of_group && matches!(self.content, DatagramContent::Status(_)) { + return unenc("STATUS and END_OF_GROUP together"); + } + if self.properties.as_ref().is_some_and(Properties::is_empty) { + return unenc("the PROPERTIES bit with no properties"); + } + if matches!(self.content, DatagramContent::Status(s) if s != ObjectStatus::Normal) && self.properties.is_some() { + return unenc("properties beside a status other than Normal"); + } + let mut out = Vec::new(); + put_varint(&mut out, self.type_flags()); + put_varint(&mut out, self.track_alias); + put_varint(&mut out, self.group_id); + if self.object_id != 0 { + put_varint(&mut out, self.object_id); + } + if let Some(p) = self.publisher_priority { + out.push(p); + } + if let Some(p) = &self.properties { + let b = p.to_bytes()?; + put_varint(&mut out, b.len() as u64); + out.extend_from_slice(&b); + } + match &self.content { + DatagramContent::Status(s) => put_varint(&mut out, s.code()), + DatagramContent::Payload(p) => out.extend_from_slice(p), + } + Ok(out) + } +} + +impl Datagram { + /// Read one whole datagram. The type comes first: the padding type, or Type + /// Flags with no undefined bit, not bit 4, and not STATUS with END_OF_GROUP. + pub fn decode(b: &[u8]) -> WireResult { + use datagram_flags::*; + let mut r = Reader::new(b); + let t = r.varint("datagram type")?; + if t == PADDING_DATAGRAM { + return Ok(Datagram::Padding(r.remaining())); + } + if t & !DEFINED != 0 || t & (STATUS | END_OF_GROUP) == (STATUS | END_OF_GROUP) { + return Err(WireError::InvalidFlags { what: "OBJECT_DATAGRAM", value: t }); + } + const W: &str = "OBJECT_DATAGRAM"; + let track_alias = r.varint(W)?; + let group_id = r.varint(W)?; + let object_id = if t & ZERO_OBJECT_ID != 0 { 0 } else { r.varint(W)? }; + let publisher_priority = if t & DEFAULT_PRIORITY != 0 { None } else { Some(r.u8(W)?) }; + let properties = if t & PROPERTIES != 0 { + let pb = r.length_prefixed(None, "object properties")?; + if pb.is_empty() { + return Err(WireError::EmptyDatagramProperties); + } + Some(Properties::decode(pb, "object properties")?) + } else { + None + }; + let content = if t & STATUS != 0 { + let s = ObjectStatus::from_code(r.varint("object status")?)?; + if s != ObjectStatus::Normal && properties.is_some() { + return Err(WireError::PropertiesOnNonNormal); + } + r.finish("OBJECT_DATAGRAM with a status")?; + DatagramContent::Status(s) + } else { + DatagramContent::Payload(r.rest().to_vec()) + }; + if let Some(p) = &properties { + p.check_object(group_id, object_id)?; + } + Ok(Datagram::Object(ObjectDatagram { track_alias, group_id, object_id, publisher_priority, properties, end_of_group: t & END_OF_GROUP != 0, content })) + } + + /// A padding datagram of `n` zero bytes after its type. + pub fn padding(n: usize) -> Vec { + let mut out = varint::to_vec(PADDING_DATAGRAM); + out.resize(out.len() + n, 0); + out + } +} + +// ── Fetch streams (§11.4) ─────────────────────────────────────────────────── + +/// FETCH_HEADER (§11.4.1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FetchHeader { + /// The FETCH, SUBSCRIBE, PUBLISH or REQUEST_UPDATE this stream answers. + pub request_id: u64, +} + +impl FetchHeader { + pub fn encode(&self, out: &mut Vec) { + put_varint(out, stream_type::FETCH_HEADER); + put_varint(out, self.request_id); + } +} + +/// The three End of Range indicators (§11.4.1.2, Table 8). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum RangeEnd { + NonExistent, + Unknown, + TimedOut, +} + +impl RangeEnd { + pub fn flags(self) -> u64 { + match self { + RangeEnd::NonExistent => 0x8c, + RangeEnd::Unknown => 0x10c, + RangeEnd::TimedOut => 0x20c, + } + } + pub fn from_flags(v: u64) -> Option { + match v { + 0x8c => Some(RangeEnd::NonExistent), + 0x10c => Some(RangeEnd::Unknown), + 0x20c => Some(RangeEnd::TimedOut), + _ => None, + } + } +} + +/// One object on a fetch stream (§11.4.1, Figure 29). Fetch objects carry no +/// status. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct FetchObject { + pub group_id: u64, + pub object_id: u64, + /// `None` for an object whose Delivery Mode is Datagram (bit 0x40). + pub subgroup_id: Option, + pub publisher_priority: u8, + /// `Some` sets bit 0x20 (a Properties field, possibly empty). + pub properties: Option, + pub payload: Vec, +} + +/// What follows a FETCH_HEADER, one entry at a time. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum FetchEntry { + Object(FetchObject), + /// Every Location after the previous entry, up to and including `location`, + /// is of this kind. + EndOfRange { kind: RangeEnd, location: Location }, +} + +/// Serialization Flags bits of a fetch object (§11.4.1.1). +pub mod fetch_flags { + pub const SUBGROUP_MODE: u64 = 0x03; + pub const OBJECT_ID_DELTA: u64 = 0x04; + pub const GROUP_ID_DELTA: u64 = 0x08; + pub const PRIORITY: u64 = 0x10; + pub const PROPERTIES: u64 = 0x20; + pub const DATAGRAM: u64 = 0x40; +} + +/// The entries of one fetch stream, in order: each refers to the prior one +/// (§11.4.1). End of Range follows the reading of -22 in which its Group ID and +/// Object ID are the delta-coded fields its flag bits 0x08 and 0x04 mark, read +/// as an object's are: the first entry's are absolute; after that the Group ID +/// moves one or more groups in the request's Group Order and the Object ID is +/// absolute. +#[derive(Clone, Debug)] +pub struct FetchCodec { + pub order: GroupOrder, + /// The prior entry's Location (an object's or an End of Range's). + prior: Option, + /// The last actual object's Subgroup ID (`None` inside: it was a datagram). + prior_subgroup: Option>, + prior_priority: Option, +} + +impl FetchCodec { + pub fn new(order: GroupOrder) -> Self { + FetchCodec { order, prior: None, prior_subgroup: None, prior_priority: None } + } + + fn next_group(&self, prior: u64, delta: u64) -> Option { + let step = delta.checked_add(1)?; + match self.order { + GroupOrder::Ascending => prior.checked_add(step), + GroupOrder::Descending => prior.checked_sub(step), + } + } + + fn group_delta(&self, prior: u64, g: u64) -> Option { + match self.order { + GroupOrder::Ascending if g > prior => Some(g - prior - 1), + GroupOrder::Descending if g < prior => Some(prior - g - 1), + _ => None, + } + } + + pub fn encode(&mut self, e: &FetchEntry, out: &mut Vec) -> WireResult<()> { + use fetch_flags::*; + let unenc = |reason| Err(WireError::Unencodable { what: "fetch entry", reason }); + match e { + FetchEntry::EndOfRange { kind, location } => { + let gd = match self.prior { + None => location.group, + Some(p) => match self.group_delta(p.group, location.group) { + Some(d) => d, + None => return unenc("an End of Range moves to a later group in the Group Order"), + }, + }; + put_varint(out, kind.flags()); + put_varint(out, gd); + put_varint(out, location.object); + self.prior = Some(*location); + } + FetchEntry::Object(o) => { + let mut flags = 0u64; + let mut fields = Vec::new(); + // Group. + let group_present = match self.prior { + None => { + put_varint(&mut fields, o.group_id); + true + } + Some(p) if p.group == o.group_id => false, + Some(p) => match self.group_delta(p.group, o.group_id) { + Some(d) => { + put_varint(&mut fields, d); + true + } + None => return unenc("groups follow the Group Order"), + }, + }; + if group_present { + flags |= GROUP_ID_DELTA; + } + // Subgroup. + match o.subgroup_id { + None => flags |= DATAGRAM, + Some(s) => { + let prior = self.prior_subgroup.flatten(); + if s == 0 { + } else if prior == Some(s) { + flags |= 0x01; + } else if prior.and_then(|p| p.checked_add(1)) == Some(s) { + flags |= 0x02; + } else { + flags |= 0x03; + put_varint(&mut fields, s); + } + } + } + // Object. + if group_present { + flags |= OBJECT_ID_DELTA; + put_varint(&mut fields, o.object_id); + } else { + let p = self.prior.expect("a prior entry when the group is absent").object; + if p.checked_add(1) == Some(o.object_id) { + } else if o.object_id > p { + flags |= OBJECT_ID_DELTA; + put_varint(&mut fields, o.object_id - p); + } else { + return unenc("Object IDs ascend within a group"); + } + } + // Priority. + if self.prior_priority != Some(o.publisher_priority) { + flags |= PRIORITY; + fields.push(o.publisher_priority); + } + // Properties. + if let Some(p) = &o.properties { + flags |= PROPERTIES; + let b = p.to_bytes()?; + put_varint(&mut fields, b.len() as u64); + fields.extend_from_slice(&b); + } + put_varint(out, flags); + out.extend_from_slice(&fields); + put_varint(out, o.payload.len() as u64); + out.extend_from_slice(&o.payload); + self.prior = Some(Location::new(o.group_id, o.object_id)); + self.prior_subgroup = Some(o.subgroup_id); + self.prior_priority = Some(o.publisher_priority); + } + } + Ok(()) + } + + /// Read the next entry, field by field in the order of Figure 29, refusing + /// at the first fault: Serialization Flags of 128 or more other than the + /// three End of Range values; a reference to a prior field when there is + /// none; a Group ID or Object ID past the 64-bit range. + pub fn decode(&mut self, r: &mut Reader<'_>) -> WireResult { + use fetch_flags::*; + const W: &str = "fetch object"; + let flags = r.varint("serialization flags")?; + if flags >= 0x80 { + let kind = RangeEnd::from_flags(flags).ok_or(WireError::InvalidFlags { what: "fetch serialization flags", value: flags })?; + let gd = r.varint("end of range")?; + let od = r.varint("end of range")?; + let group = match self.prior { + None => gd, + Some(p) => self.next_group(p.group, gd).ok_or(WireError::Overflow { what: "Group ID" })?, + }; + let location = Location::new(group, od); + self.prior = Some(location); + return Ok(FetchEntry::EndOfRange { kind, location }); + } + let group_id = if flags & GROUP_ID_DELTA != 0 { + let d = r.varint(W)?; + match self.prior { + None => d, + Some(p) => self.next_group(p.group, d).ok_or(WireError::Overflow { what: "Group ID" })?, + } + } else { + self.prior.ok_or(WireError::NoPriorObject { field: "Group ID" })?.group + }; + let subgroup_id = if flags & DATAGRAM != 0 { + None + } else { + let prior = || self.prior_subgroup.flatten().ok_or(WireError::NoPriorObject { field: "Subgroup ID" }); + Some(match flags & SUBGROUP_MODE { + 0 => 0, + 1 => prior()?, + 2 => prior()?.checked_add(1).ok_or(WireError::Overflow { what: "Subgroup ID" })?, + _ => r.varint(W)?, + }) + }; + let object_id = if flags & OBJECT_ID_DELTA != 0 { + let d = r.varint(W)?; + if flags & GROUP_ID_DELTA != 0 { + d + } else { + self.prior.ok_or(WireError::NoPriorObject { field: "Object ID" })?.object.checked_add(d).ok_or(WireError::Overflow { what: "Object ID" })? + } + } else { + self.prior.ok_or(WireError::NoPriorObject { field: "Object ID" })?.object.checked_add(1).ok_or(WireError::Overflow { what: "Object ID" })? + }; + let publisher_priority = if flags & PRIORITY != 0 { r.u8(W)? } else { self.prior_priority.ok_or(WireError::NoPriorObject { field: "Priority" })? }; + let properties = if flags & PROPERTIES != 0 { + Some(Properties::decode(r.length_prefixed(None, "object properties")?, "object properties")?) + } else { + None + }; + let len = r.varint("object payload length")?; + let payload = r.bytes(len, "object payload")?.to_vec(); + if let Some(p) = &properties { + p.check_object(group_id, object_id)?; + } + self.prior = Some(Location::new(group_id, object_id)); + self.prior_subgroup = Some(subgroup_id); + self.prior_priority = Some(publisher_priority); + Ok(FetchEntry::Object(FetchObject { group_id, object_id, subgroup_id, publisher_priority, properties, payload })) + } +} + +// ── Whole unidirectional streams ───────────────────────────────────────────── + +/// What a unidirectional stream begins with (§6.4.1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StreamHeader { + /// A control stream: its first message, SETUP, follows. + Control, + Subgroup(SubgroupHeader), + Fetch(FetchHeader), + Padding, +} + +/// One thing a unidirectional stream carries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UniEvent { + Header(StreamHeader), + Control(ControlMessage), + SubgroupObject(SubgroupObject), + FetchEntry(FetchEntry), + /// Padding bytes, discarded (§11.5.1). + Padding(usize), +} + +/// Read a stream type and what of the header follows it. +fn decode_header(r: &mut Reader<'_>) -> WireResult { + let t = r.varint("stream type")?; + match t { + stream_type::FETCH_HEADER => Ok(StreamHeader::Fetch(FetchHeader { request_id: r.varint("FETCH_HEADER")? })), + stream_type::SETUP => Ok(StreamHeader::Control), + stream_type::PADDING => Ok(StreamHeader::Padding), + t if SubgroupHeader::is_subgroup_type(t) => Ok(StreamHeader::Subgroup(SubgroupHeader::decode_after_type(t, r)?)), + t => Err(WireError::UnknownStreamType(t)), + } +} + +/// The SETUP that opens a control stream, after its type: Length, then options. +fn decode_setup_after_type(r: &mut Reader<'_>) -> WireResult { + let len = r.u16("message length")?; + let mut b = r.sub(len as u64, "message body")?; + let options = SetupOptions::decode(&mut b)?; + b.finish("SETUP")?; + Ok(ControlMessage::Setup(Setup { options })) +} + +/// Read a whole unidirectional stream, its bytes up to the FIN. `order` is the +/// Group Order of the request a fetch stream answers (Ascending when it set +/// none, §9.20.8). +pub fn decode_uni_stream(b: &[u8], order: GroupOrder) -> WireResult> { + let mut r = Reader::new(b); + let header = decode_header(&mut r)?; + let mut out = vec![UniEvent::Header(header.clone())]; + match header { + StreamHeader::Control => { + let mut st = ControlStreamState::new(); + let setup = decode_setup_after_type(&mut r)?; + st.on_message(&setup)?; + out.push(UniEvent::Control(setup)); + while !r.is_empty() { + let m = ControlMessage::decode(&mut r)?; + st.on_message(&m)?; + out.push(UniEvent::Control(m)); + } + } + StreamHeader::Subgroup(h) => { + let mut c = SubgroupCodec::new(h); + while !r.is_empty() { + out.push(UniEvent::SubgroupObject(c.decode_object(&mut r)?)); + } + } + StreamHeader::Fetch(_) => { + let mut c = FetchCodec::new(order); + while !r.is_empty() { + out.push(UniEvent::FetchEntry(c.decode(&mut r)?)); + } + } + StreamHeader::Padding => { + let n = r.rest().len(); + if n > 0 { + out.push(UniEvent::Padding(n)); + } + } + } + Ok(out) +} + +// ── The incremental decoder ───────────────────────────────────────────────── + +/// Limits of this implementation on what one object may make it buffer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + pub max_payload: u64, + pub max_properties: u64, +} + +impl Default for Limits { + fn default() -> Self { + Limits { max_payload: 16 * 1024 * 1024, max_properties: 1024 * 1024 } + } +} + +enum Body { + Start, + Control(ControlStreamState, bool), + Subgroup(SubgroupCodec), + Fetch(FetchCodec), + Padding, +} + +/// A unidirectional stream's decoder that takes bytes as they arrive. +pub struct UniStreamDecoder { + buf: Vec, + body: Body, + order: GroupOrder, + limits: Limits, +} + +/// Walk a varint at `*at` in `b` without parsing more: `Ok(None)` when it is not +/// all there. +fn walk_varint(b: &[u8], at: &mut usize) -> Option { + let (v, n) = varint::get(&b[*at..], "").ok()?; + *at += n; + Some(v) +} + +impl UniStreamDecoder { + pub fn new(order: GroupOrder, limits: Limits) -> Self { + UniStreamDecoder { buf: Vec::new(), body: Body::Start, order, limits } + } + + /// Set the Group Order of the fetch this stream answers, once its header + /// has said which request that is. + pub fn set_group_order(&mut self, order: GroupOrder) { + self.order = order; + if let Body::Fetch(c) = &mut self.body { + c.order = order; + } + } + + pub fn push(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Bytes held that are not yet a whole item. + pub fn buffered(&self) -> usize { + self.buf.len() + } + + fn take(&mut self, n: usize) { + self.buf.drain(..n); + } + + fn need_payload(&self, len: u64, props: u64) -> WireResult<()> { + if len > self.limits.max_payload { + return Err(WireError::LocalLimit { what: "object payload", len, limit: self.limits.max_payload }); + } + if props > self.limits.max_properties { + return Err(WireError::LocalLimit { what: "object properties", len: props, limit: self.limits.max_properties }); + } + Ok(()) + } + + /// The length of the next whole item in the buffer, or `None` when it is not + /// all there. + fn frame(&self) -> WireResult> { + let b = &self.buf[..]; + let mut at = 0usize; + macro_rules! v { + () => { + match walk_varint(b, &mut at) { + Some(v) => v, + None => return Ok(None), + } + }; + } + macro_rules! skip { + ($n:expr) => {{ + let n: u64 = $n; + if ((b.len() - at) as u64) < n { + return Ok(None); + } + at += n as usize; + }}; + } + match &self.body { + Body::Start => { + let t = v!(); + match t { + stream_type::FETCH_HEADER => { + v!(); + } + stream_type::SETUP | stream_type::PADDING => {} + t if SubgroupHeader::is_subgroup_type(t) => { + if (t & subgroup_flags::SUBGROUP_ID_MODE) >> 1 == 0b11 { + return Err(WireError::InvalidFlags { what: "SUBGROUP_HEADER", value: t }); + } + v!(); + v!(); + if (t & subgroup_flags::SUBGROUP_ID_MODE) >> 1 == 0b10 { + v!(); + } + if t & subgroup_flags::DEFAULT_PRIORITY == 0 { + skip!(1); + } + } + t => return Err(WireError::UnknownStreamType(t)), + } + } + Body::Control(_, setup_read) => { + if *setup_read { + v!(); + } + skip!(2); + let len = u16::from_be_bytes([b[at - 2], b[at - 1]]) as u64; + skip!(len); + } + Body::Subgroup(c) => { + v!(); + let mut props = 0; + if c.header.properties { + props = v!(); + self.need_payload(0, props)?; + skip!(props); + } + let len = v!(); + self.need_payload(len, props)?; + if len == 0 { + v!(); + } + skip!(len); + } + Body::Fetch(_) => { + use fetch_flags::*; + let flags = v!(); + if flags >= 0x80 { + if RangeEnd::from_flags(flags).is_none() { + return Err(WireError::InvalidFlags { what: "fetch serialization flags", value: flags }); + } + v!(); + v!(); + } else { + if flags & GROUP_ID_DELTA != 0 { + v!(); + } + if flags & DATAGRAM == 0 && flags & SUBGROUP_MODE == 3 { + v!(); + } + if flags & OBJECT_ID_DELTA != 0 { + v!(); + } + if flags & PRIORITY != 0 { + skip!(1); + } + let mut props = 0; + if flags & PROPERTIES != 0 { + props = v!(); + self.need_payload(0, props)?; + skip!(props); + } + let len = v!(); + self.need_payload(len, props)?; + skip!(len); + } + } + Body::Padding => at = b.len(), + } + Ok(Some(at)) + } + + /// The next item, `Ok(None)` when more bytes are needed. + pub fn next(&mut self) -> WireResult> { + if self.buf.is_empty() && !matches!(self.body, Body::Start) { + return Ok(None); + } + let Some(n) = self.frame()? else { return Ok(None) }; + if n == 0 { + return Ok(None); + } + let item = self.buf[..n].to_vec(); + let mut r = Reader::new(&item); + if matches!(self.body, Body::Start) { + let h = decode_header(&mut r)?; + r.finish("stream header")?; + self.body = match &h { + StreamHeader::Control => Body::Control(ControlStreamState::new(), false), + StreamHeader::Subgroup(s) => Body::Subgroup(SubgroupCodec::new(*s)), + StreamHeader::Fetch(_) => Body::Fetch(FetchCodec::new(self.order)), + StreamHeader::Padding => Body::Padding, + }; + self.take(n); + return Ok(Some(UniEvent::Header(h))); + } + let ev = match &mut self.body { + // Handled above; kept total so no input can reach a panic. + Body::Start => return Ok(None), + Body::Control(st, setup_read) => { + let m = if *setup_read { ControlMessage::decode(&mut r)? } else { decode_setup_after_type(&mut r)? }; + st.on_message(&m)?; + *setup_read = true; + UniEvent::Control(m) + } + Body::Subgroup(c) => UniEvent::SubgroupObject(c.decode_object(&mut r)?), + Body::Fetch(c) => UniEvent::FetchEntry(c.decode(&mut r)?), + Body::Padding => UniEvent::Padding(n), + }; + r.finish("stream item")?; + self.take(n); + Ok(Some(ev)) + } + + /// At the stream's FIN: bytes left over are an item cut short (§11.3: a FIN + /// inside an object), and a stream with no header is refused. + pub fn finish(&self) -> WireResult<()> { + if !self.buf.is_empty() || matches!(self.body, Body::Start) { + return Err(WireError::Truncated { what: "stream ends inside an item" }); + } + Ok(()) + } +} + +/// Encode a SUBGROUP_HEADER and its objects as one stream's bytes. +pub fn encode_subgroup_stream(header: &SubgroupHeader, objects: &[SubgroupObject]) -> WireResult> { + let mut out = Vec::new(); + header.encode(&mut out); + let mut c = SubgroupCodec::new(*header); + for o in objects { + c.encode_object(o, &mut out)?; + } + Ok(out) +} + +/// Encode a FETCH_HEADER and its entries as one stream's bytes. +pub fn encode_fetch_stream(header: &FetchHeader, order: GroupOrder, entries: &[FetchEntry]) -> WireResult> { + let mut out = Vec::new(); + header.encode(&mut out); + let mut c = FetchCodec::new(order); + for e in entries { + c.encode(e, &mut out)?; + } + Ok(out) +} + +/// Encode a control stream: SETUP, then the messages that may follow it. +pub fn encode_control_stream(setup: &Setup, then: &[ControlMessage]) -> WireResult> { + let mut out = ControlMessage::Setup(setup.clone()).encode()?; + debug_assert_eq!(varint::get(&out, "").map(|x| x.0), Ok(msg::SETUP)); + for m in then { + out.extend_from_slice(&m.encode()?); + } + Ok(out) +} + +/// A padding stream of `n` zero bytes after its type. +pub fn encode_padding_stream(n: usize) -> Vec { + let mut out = varint::to_vec(stream_type::PADDING); + out.resize(out.len() + n, 0); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::draft22::kvp::KeyValuePair; + + fn hdr(props: bool) -> SubgroupHeader { + SubgroupHeader { track_alias: 2, group_id: 5, subgroup_id: SubgroupId::FirstObjectId, publisher_priority: Some(64), properties: props, end_of_group: true, first_object: true } + } + + #[test] + fn a_subgroup_stream_round_trips_and_decodes_incrementally() { + let mut p = Properties::new(); + p.push(KeyValuePair::varint(0x3c, 2)); + let objs = vec![ + SubgroupObject { object_id: 3, properties: p, status: ObjectStatus::Normal, payload: b"abc".to_vec() }, + SubgroupObject::normal(4, vec![]), + SubgroupObject { object_id: 9, properties: Properties::new(), status: ObjectStatus::EndOfGroup, payload: vec![] }, + ]; + let b = encode_subgroup_stream(&hdr(true), &objs).unwrap(); + let ev = decode_uni_stream(&b, GroupOrder::Ascending).unwrap(); + assert_eq!(ev[0], UniEvent::Header(StreamHeader::Subgroup(hdr(true)))); + assert_eq!(ev[1..].iter().cloned().collect::>(), objs.iter().cloned().map(UniEvent::SubgroupObject).collect::>()); + // Byte by byte, the same. + let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default()); + let mut got = Vec::new(); + for byte in &b { + d.push(&[*byte]); + while let Some(e) = d.next().unwrap() { + got.push(e); + } + } + d.finish().unwrap(); + assert_eq!(got, ev); + // A FIN inside the last object. + assert!(matches!(decode_uni_stream(&b[..b.len() - 1], GroupOrder::Ascending), Err(WireError::Truncated { .. }))); + let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default()); + d.push(&b[..b.len() - 1]); + while d.next().unwrap().is_some() {} + assert!(d.finish().is_err()); + } + + #[test] + fn a_fetch_stream_in_both_orders() { + for order in [GroupOrder::Ascending, GroupOrder::Descending] { + let (g0, g1, g2) = match order { + GroupOrder::Ascending => (1, 4, 9), + GroupOrder::Descending => (9, 4, 1), + }; + let entries = vec![ + FetchEntry::Object(FetchObject { group_id: g0, object_id: 0, subgroup_id: Some(0), publisher_priority: 7, properties: None, payload: b"a".to_vec() }), + FetchEntry::Object(FetchObject { group_id: g0, object_id: 1, subgroup_id: Some(0), publisher_priority: 7, properties: None, payload: b"b".to_vec() }), + FetchEntry::Object(FetchObject { group_id: g0, object_id: 5, subgroup_id: Some(1), publisher_priority: 9, properties: Some(Properties::new()), payload: vec![] }), + FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(g1, 3) }, + FetchEntry::Object(FetchObject { group_id: g1, object_id: 4, subgroup_id: None, publisher_priority: 9, properties: None, payload: b"d".to_vec() }), + FetchEntry::Object(FetchObject { group_id: g2, object_id: 0, subgroup_id: Some(7), publisher_priority: 9, properties: None, payload: b"e".to_vec() }), + ]; + let b = encode_fetch_stream(&FetchHeader { request_id: 6 }, order, &entries).unwrap(); + let ev = decode_uni_stream(&b, order).unwrap(); + assert_eq!(ev[1..].to_vec(), entries.iter().cloned().map(UniEvent::FetchEntry).collect::>(), "{order:?}"); + } + } + + #[test] + fn a_first_fetch_object_that_refers_back_is_refused() { + // Flags 0x0c|0x10 with subgroup mode 0x01 (prior subgroup) first. + let mut b = Vec::new(); + FetchHeader { request_id: 0 }.encode(&mut b); + b.extend_from_slice(&[0x1d, 0, 0, 0x80, 0]); + assert_eq!(decode_uni_stream(&b, GroupOrder::Ascending), Err(WireError::NoPriorObject { field: "Subgroup ID" })); + } + + #[test] + fn datagrams() { + let d = ObjectDatagram { + track_alias: 1, + group_id: 2, + object_id: 0, + publisher_priority: None, + properties: Some({ + let mut p = Properties::new(); + p.push(KeyValuePair::bytes(0x3a41, vec![1, 2])); + p + }), + end_of_group: true, + content: DatagramContent::Payload(b"xyz".to_vec()), + }; + let b = d.encode().unwrap(); + assert_eq!(Datagram::decode(&b).unwrap(), Datagram::Object(d)); + for bad in [0x10u64, 0x22, 0x40, 0x2f00] { + let mut b = varint::to_vec(bad); + b.extend_from_slice(&[0, 0, 0]); + assert!(matches!(Datagram::decode(&b), Err(WireError::InvalidFlags { .. })), "{bad:#x}"); + } + assert_eq!(Datagram::decode(&Datagram::padding(3)).unwrap(), Datagram::Padding(3)); + // PROPERTIES with a zero length. + assert_eq!(Datagram::decode(&[0x0d, 1, 2, 0]), Err(WireError::EmptyDatagramProperties)); + } + + #[test] + fn subgroup_header_flags() { + for bad in [0x16u64, 0x0f, 0x90] { + let mut b = varint::to_vec(bad); + b.extend_from_slice(&[0, 0, 0, 0]); + assert!(decode_uni_stream(&b, GroupOrder::Ascending).is_err(), "{bad:#x}"); + } + let mut b = varint::to_vec(0x90); + b.push(0); + assert_eq!(decode_uni_stream(&b, GroupOrder::Ascending), Err(WireError::UnknownStreamType(0x90))); + assert_eq!(hdr(false).type_flags(), 0x10 | 0x02 | 0x08 | 0x40); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs new file mode 100644 index 000000000..d07081ad9 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs @@ -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 }, + /// 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 = Result; diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs new file mode 100644 index 000000000..6b6a85caa --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs @@ -0,0 +1,143 @@ +//! 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 { + let (v, n) = varint::get(&self.buf[self.pos..], what)?; + self.pos += n; + Ok(v) + } + + pub fn u8(&mut self, what: &'static str) -> WireResult { + 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 { + 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> { + 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, what: &'static str) -> WireResult<&'a [u8]> { + let n = self.varint(what)?; + if let Some(limit) = limit { + if n > limit { + 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, v: u64) { + varint::put(out, v) +} + +/// Append a varint length, then the bytes; refused past `limit`. +pub fn put_length_prefixed(out: &mut Vec, b: &[u8], limit: Option, what: &'static str) -> WireResult<()> { + if let Some(limit) = limit { + if b.len() as u64 > limit { + 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 })); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs new file mode 100644 index 000000000..4c1fb14b0 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs @@ -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| { + 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 { + 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 { + 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}) +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs new file mode 100644 index 000000000..5eb5fdc16 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs @@ -0,0 +1,409 @@ +//! 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), +} + +/// 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>) -> Self { + KeyValuePair { ty, value: KvValue::Bytes(b.into()) } + } + pub fn as_varint(&self) -> Option { + 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) -> 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> { + 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 = 0x4000..=0x7fff; + /// Ranges an application uses without registration (§8.4). + pub const APPLICATION_1BYTE: std::ops::RangeInclusive = 0x78..=0x7f; + pub const APPLICATION_2BYTE: std::ops::RangeInclusive = 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); + +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) -> WireResult<()> { + encode_pairs(&self.0, out) + } + + pub fn to_bytes(&self) -> WireResult> { + 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 { + 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> { + 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> { + let mut out: Vec = 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> { + Ok(self.find_all(ty)?.into_iter().next()) + } + + /// Both lists, the mutable one first, without the Immutable Properties pair. + pub fn flattened(&self) -> WireResult> { + let mut out: Vec = 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 let Some(inner) = self.immutable()? { + if 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 = 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 let Some(inner) = self.immutable()? { + if 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> { + 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); + +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) -> WireResult<()> { + self.check()?; + encode_pairs(&self.0, out) + } + + pub fn decode(r: &mut Reader<'_>) -> WireResult { + 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 { + 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 { + 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); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs new file mode 100644 index 000000000..fce3107a6 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs @@ -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; diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs new file mode 100644 index 000000000..70a2c8eb9 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs @@ -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) { + put_varint(out, self.group); + put_varint(out, self.object); + } + + pub fn decode(r: &mut Reader<'_>, what: &'static str) -> WireResult { + 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) -> 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 { + 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) -> 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 { + 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> { + 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)> { + 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::>>()? + }; + 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"]))); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs new file mode 100644 index 000000000..791675d78 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs @@ -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 { + 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 { + 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 { + 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) -> 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 { + 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, +} + +/// 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, + pub ranges: Vec, +} + +impl RangeFilter { + fn encode_body(&self, ty: u64, out: &mut Vec) -> 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 { + 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 }, + 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) -> 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 { + use param::*; + let flag = |r: &mut Reader<'_>, what: &'static str| -> WireResult { + 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, Option)> { + 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); + +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, 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, 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 { + 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 { + 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 { + self.0.iter().find_map(|p| match p { + Parameter::LocationFilter(f) => Some(*f), + _ => None, + }) + } + pub fn largest_object(&self) -> Option { + self.0.iter().find_map(|p| match p { + Parameter::LargestObject(l) => Some(*l), + _ => None, + }) + } + pub fn group_order(&self) -> Option { + 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 { + 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 { + self.0.iter().find_map(|p| match p { + Parameter::SubscriberPriority(v) => Some(*v), + _ => None, + }) + } + pub fn expires(&self) -> Option { + self.0.iter().find_map(|p| match p { + Parameter::Expires(v) => Some(*v), + _ => None, + }) + } + pub fn object_delivery_timeout(&self) -> Option { + self.0.iter().find_map(|p| match p { + Parameter::ObjectDeliveryTimeout(v) => Some(*v), + _ => None, + }) + } + pub fn range_filters(&self) -> impl Iterator)> { + self.0.iter().filter_map(|p| match p { + Parameter::RangeFilter { ty, filter } => Some((*ty, filter.as_ref())), + _ => None, + }) + } +} + +#[derive(Default)] +struct DecodeState { + out: Vec, + 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); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs new file mode 100644 index 000000000..4921af25d --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs @@ -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, +} + +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 { + 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, + 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, +} + +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 { + 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 { + 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()); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs new file mode 100644 index 000000000..d8940d3f3 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs @@ -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 }, + /// Use the value registered under `alias`. + UseAlias { alias: u64 }, + /// Use `value` as given. + UseValue { token_type: u64, value: Vec }, +} + +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 { + 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 { + 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 { .. }))); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs new file mode 100644 index 000000000..55409c37a --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs @@ -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, 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, 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 { + let mut out = Vec::with_capacity(9); + put(&mut out, v); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(s: &str) -> Vec { + (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" })); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/error.rs b/joule-code/joule-code-rs/crates/joule-moq/src/error.rs index 4d6289ddc..bd6d6fd8e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/error.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/error.rs @@ -56,6 +56,9 @@ pub enum MoqError { #[error("jwp: {0}")] Jwp(String), + + #[error("moqt wire: {0}")] + Wire(#[from] crate::draft22::WireError), } impl From for MoqError { diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs index c7cc76984..c4426208e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs @@ -43,6 +43,8 @@ //! by construction. There is no "raw" object path that bypasses receipt //! generation. +pub mod draft; +pub mod draft22; pub mod endpoint; pub mod error; pub mod messages; -- 2.54.0 (Apple Git-157) From 908ec8e667cf7f6ed8233461817fbd375da52fda Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:07:07 -0400 Subject: [PATCH 2/7] joule-moq: the session, mock, receipts and QUIC transport move onto draft-22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - messages: the control plane is -22's; ObjectHeader stays draft-neutral, and a layer cap is an OBJECT_PROPERTY_FILTER on the MRL_LAYER property. - carriage: the crate's objects ride -22 subgroup objects, their receipt, the WAI_RECEIPT property (moq-streaming-format §5) and the MRL fields in Immutable Properties at application-range code points; a group's receipts ride its receipts track (§4.1). - session: the draft by ALPN, SETUP options, the JWP extension declared by a Setup Option, Request IDs per end. - mock: objects, receipt bundles and requests cross the -22 codec; the relay reads an object's delivery timeout from its OBJECT_DELIVERY_TIMEOUT property. - transport_quic: ALPN moqt-22, a pair of unidirectional control streams, a request stream per request, subgroup and fetch streams, datagrams. - wire: MOQT's integer is the -22 varint; every u64 encodes. - MOQT_DRAFT_VERSION is 0xff000016. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq/examples/quic_xnet.rs | 61 +- .../crates/joule-moq/src/carriage.rs | 477 +++++++++ .../crates/joule-moq/src/draft22/data.rs | 20 +- .../joule-code-rs/crates/joule-moq/src/lib.rs | 72 +- .../crates/joule-moq/src/messages.rs | 544 ++--------- .../crates/joule-moq/src/mock.rs | 100 +- .../crates/joule-moq/src/receipt.rs | 2 +- .../crates/joule-moq/src/session.rs | 234 +++-- .../crates/joule-moq/src/track.rs | 16 +- .../crates/joule-moq/src/transport_quic.rs | 917 +++++++++++------- .../crates/joule-moq/src/wire.rs | 104 +- 11 files changed, 1519 insertions(+), 1028 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs b/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs index d0bf94bd9..34598d07d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/examples/quic_xnet.rs @@ -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> { 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 = 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> { 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> { 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> { 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> { 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)", diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs b/joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs new file mode 100644 index 000000000..a1f1c3a49 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs @@ -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, + /// 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 { + 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 { + 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 { + 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 { + 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, + pub wai_receipt: Option, + /// OBJECT_DELIVERY_TIMEOUT set on the object (§5.2, §10.2). + pub delivery_timeout_ms: Option, +} + +/// 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 { + 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 { + let small = |v: Option, what: &str| -> MoqResult { + 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> { 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 { + 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> { + 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, +} + +#[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> { + let mut items: Vec = 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 { + let v: Vec = 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::>>()?; + 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> { + 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>, 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); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs index 61a7bc670..68218b80f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs @@ -986,7 +986,7 @@ impl UniStreamDecoder { } Body::Subgroup(c) => UniEvent::SubgroupObject(c.decode_object(&mut r)?), Body::Fetch(c) => UniEvent::FetchEntry(c.decode(&mut r)?), - Body::Padding => UniEvent::Padding(n), + Body::Padding => UniEvent::Padding(r.rest().len()), }; r.finish("stream item")?; self.take(n); @@ -1140,6 +1140,24 @@ mod tests { assert_eq!(Datagram::decode(&[0x0d, 1, 2, 0]), Err(WireError::EmptyDatagramProperties)); } + #[test] + fn padding_streams_are_discarded_as_they_arrive() { + let b = encode_padding_stream(5); + assert_eq!(decode_uni_stream(&b, GroupOrder::Ascending).unwrap(), vec![UniEvent::Header(StreamHeader::Padding), UniEvent::Padding(5)]); + let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default()); + let mut n = 0; + for chunk in b.chunks(3) { + d.push(chunk); + while let Some(e) = d.next().unwrap() { + if let UniEvent::Padding(k) = e { + n += k; + } + } + } + d.finish().unwrap(); + assert_eq!(n, 5); + } + #[test] fn subgroup_header_flags() { for bad in [0x16u64, 0x0f, 0x90] { diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs index c4426208e..97604259b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs @@ -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,7 @@ //! by construction. There is no "raw" object path that bypasses receipt //! generation. +pub mod carriage; pub mod draft; pub mod draft22; pub mod endpoint; @@ -59,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, @@ -83,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; diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs b/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs index 05a86db40..a41e5baf9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs @@ -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 }, -} - -/// 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, - /// 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, -} - -/// 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, -} - -/// 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, -} - -#[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 { - 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 { - 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 { - 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 { - 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 { + 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); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs index 53dfc27ad..f190331ee 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs @@ -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) { + if 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>, track_aliases: Mutex>, + /// The publisher is the server end of its mock session: odd Request IDs. + request_ids: Mutex, } 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, - next_subscribe_id: AtomicU64, + /// The subscriber is the client end of its mock session: even Request IDs. + request_ids: Mutex, } impl MockSubscriber { pub fn new(fabric: Arc) -> 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 { + Ok(ControlMessage::decode_exact(&m.encode()?)?) +} + #[async_trait] impl MoqEndpoint for MockSubscriber { fn version(&self) -> Option { @@ -281,17 +314,26 @@ impl Subscriber for MockSubscriber { _require_receipts: bool, ) -> MoqResult { 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, -} - /// Something the relay sent, and when, on the caller's clock. #[derive(Clone, Debug)] pub struct Sent { @@ -483,6 +517,13 @@ impl MockRelay { object_timeout_ms: Option, ) -> 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 diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs index 31473818d..92bda3e77 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs @@ -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(()), diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq/src/session.rs index b7856ea5b..861e2fc71 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/session.rs @@ -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, + /// That draft's 32-bit code, once the session is established. pub negotiated_version: Option, 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, - /// Track alias → subscribe_id reverse index. + /// Track alias → Request ID reverse index. pub alias_index: HashMap, + /// 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, + /// 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) -> 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) -> MoqResult { + 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 { - 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 { + 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 { + 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); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/track.rs b/joule-code/joule-code-rs/crates/joule-moq/src/track.rs index 4d92816bb..678f61236 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/track.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/track.rs @@ -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, diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs b/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs index 25dbe2e0a..553243493 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/transport_quic.rs @@ -1,21 +1,36 @@ -//! Production QUIC transport for MoQT, gated behind the `quic` feature. +//! Production QUIC transport for MoqT, gated behind the `quic` feature. //! -//! Wraps [`quinn`] to provide: +//! `draft-ietf-moq-transport-22` over native QUIC (§6.2.2), on [`quinn`]: //! -//! - [`QuicEndpoint`] — client/server endpoint factory with rustls TLS -//! - [`QuicSession`] — wraps a `quinn::Connection` and exposes the MoQT -//! control stream, plus opens unidirectional streams per group / object. -//! - Helpers for length-prefixed control-message I/O on the control stream. +//! - [`QuicEndpoint`] — client/server endpoint factory with rustls TLS, offering +//! and accepting the ALPN [`ALPN_MOQT`] (`moqt-22`), which fixes the draft; +//! - [`QuicSession`] — one connection: its pair of unidirectional control +//! streams, each opened by a SETUP ([`QuicSession::open_control`], +//! [`QuicSession::accept_control`]); a bidirectional stream per request +//! ([`QuicSession::open_request`], [`QuicSession::accept_request`]); subgroup +//! and fetch streams ([`QuicSession::open_subgroup`], +//! [`QuicSession::open_fetch`], [`QuicSession::accept_data`]); and object +//! datagrams; +//! - the carriage of this crate's objects and receipts ([`crate::carriage`]): +//! [`send_object`], [`DataReceiver::next_object`], [`send_receipt_bundle`], +//! [`DataReceiver::read_receipt_bundle`]. //! //! A self-signed certificate generator (`generate_self_signed`) is included //! for development and integration tests; production deployments load a //! certificate chain from disk via [`ServerConfig::with_cert_chain`]. +//! +//! WebTransport (MOQT over HTTP/3, §6.2.1) is not here yet. +use crate::carriage::{from_subgroup_object, to_subgroup_object, ObjectExtras, ReceiptBundle}; +use crate::draft::Draft; +use crate::draft22::data::{encode_padding_stream, FetchCodec, Limits, SubgroupCodec}; +use crate::draft22::{ + ControlMessage, Datagram, FetchEntry, FetchHeader, GroupOrder, Goaway, ObjectDatagram, RequestStream, Setup, Side, StreamHeader, + SubgroupHeader, SubgroupId, SubgroupObject, UniEvent, UniStreamDecoder, +}; use crate::error::{MoqError, MoqResult}; -use crate::messages::{ControlMessage, ObjectHeader}; -use crate::receipt::{GroupReceipt, ObjectReceipt}; -use crate::wire::{decode_varint, encode_varint}; -use bytes::{BufMut, Bytes, BytesMut}; +use crate::messages::ObjectHeader; +use bytes::Bytes; use quinn::{ crypto::rustls::{QuicClientConfig, QuicServerConfig}, ClientConfig, Connection, Endpoint, RecvStream, SendStream, ServerConfig, @@ -32,15 +47,20 @@ fn install_default_provider() { }); } -/// MoQT ALPN identifier — the on-the-wire signal that this connection -/// speaks the MoQT protocol over QUIC. -pub const ALPN_MOQT: &[u8] = b"moq-00"; +/// MoqT ALPN identifier: `moqt-22`, the draft this transport speaks (§6.2). +/// Drafts before -15 shared `moq-00` and negotiated in SETUP; -22 does not. +pub const ALPN_MOQT: &[u8] = crate::draft22::ALPN; -/// Maximum size of a single control-plane message we'll accept (1 MiB). -pub const MAX_CONTROL_MESSAGE_BYTES: usize = 1 * 1024 * 1024; -/// Maximum size of a single object payload we'll accept (16 MiB). +/// Maximum size of a single control-plane message: its Length is 16 bits (§9). +pub const MAX_CONTROL_MESSAGE_BYTES: usize = 0xffff; +/// Maximum size of a single object payload we'll accept (16 MiB). A limit of +/// this implementation, not of the draft. pub const MAX_OBJECT_BYTES: usize = 16 * 1024 * 1024; +fn transport(what: &str) -> impl Fn(String) -> MoqError + '_ { + move |e| MoqError::Transport(format!("{what}: {e}")) +} + /// Wraps a `quinn::Endpoint` with helpers to dial / accept MoQT-shaped /// connections. pub struct QuicEndpoint { @@ -144,7 +164,7 @@ impl QuicEndpoint { } } -/// One MoQT session over a QUIC connection. +/// One MoqT session over a QUIC connection. pub struct QuicSession { pub connection: Connection, } @@ -154,40 +174,108 @@ impl QuicSession { Self { connection } } - /// Open the bidirectional control stream (caller is the initiator). - pub async fn open_control_stream(&self) -> MoqResult { - let (send, recv) = self - .connection - .open_bi() - .await - .map_err(|e| MoqError::Transport(format!("open_bi: {}", e)))?; - Ok(ControlStream { send, recv }) + /// The draft the ALPN selected: `None` when the peer agreed on none this + /// crate speaks. + pub fn negotiated_draft(&self) -> Option { + let hd = self.connection.handshake_data()?; + let hd = hd.downcast::().ok()?; + Draft::from_alpn(hd.protocol.as_deref()?) } - /// Accept the peer's control stream. - pub async fn accept_control_stream(&self) -> MoqResult { - let (send, recv) = self - .connection - .accept_bi() - .await - .map_err(|e| MoqError::Transport(format!("accept_bi: {}", e)))?; - Ok(ControlStream { send, recv }) + /// Open this endpoint's control stream and send `setup` on it (§6.3). + pub async fn open_control(&self, setup: &Setup) -> MoqResult { + let mut send = self.open_uni().await?; + let b = ControlMessage::Setup(setup.clone()).encode()?; + send.write_all(&b).await.map_err(|e| transport("control write")(e.to_string()))?; + Ok(ControlSender { send }) } - /// Open a unidirectional stream for object delivery. + /// Accept the peer's control stream and read its SETUP. This expects the + /// peer's first unidirectional stream to be its control stream. + pub async fn accept_control(&self) -> MoqResult { + let recv = self.accept_uni().await?; + let mut rx = DataReceiver::start(recv, GroupOrder::Ascending).await?; + if rx.header != StreamHeader::Control { + return Err(MoqError::Transport("the peer's first unidirectional stream is not its control stream".into())); + } + match rx.next_event().await? { + Some(UniEvent::Control(ControlMessage::Setup(s))) => Ok(ControlReceiver { rx, peer_setup: s }), + _ => Err(MoqError::Transport("the peer's control stream ended before its SETUP".into())), + } + } + + /// Open a request stream with its first message (§6.3, §6.4.2). The limits + /// are the two endpoints' MAX_REQUEST_UPDATES (0: none). + pub async fn open_request(&self, first: &ControlMessage, my_limit: u64, peer_limit: u64) -> MoqResult { + let kind = first.opens_request().ok_or_else(|| MoqError::InvalidSessionState(format!("{} does not open a request stream", first.name())))?; + let (mut send, recv) = self.connection.open_bi().await.map_err(|e| transport("open_bi")(e.to_string()))?; + send.write_all(&first.encode()?).await.map_err(|e| transport("request write")(e.to_string()))?; + Ok(QuicRequestStream { send, recv, state: RequestStream::new(kind, Side::Requester, my_limit, peer_limit) }) + } + + /// Accept the peer's next request stream and its first message, which must + /// be a request (§6.3). + pub async fn accept_request(&self, my_limit: u64, peer_limit: u64) -> MoqResult<(QuicRequestStream, ControlMessage)> { + let (send, mut recv) = self.connection.accept_bi().await.map_err(|e| transport("accept_bi")(e.to_string()))?; + let first = read_control_message(&mut recv).await?.ok_or_else(|| MoqError::Transport("a request stream closed before its request".into()))?; + let state = RequestStream::opened_by_peer(&first, my_limit, peer_limit)?; + Ok((QuicRequestStream { send, recv, state }, first)) + } + + /// Open a subgroup stream and send its header (§11.3). + pub async fn open_subgroup(&self, header: SubgroupHeader) -> MoqResult { + let mut stream = self.open_uni().await?; + let mut b = Vec::new(); + header.encode(&mut b); + stream.write_all(&b).await.map_err(|e| transport("subgroup header")(e.to_string()))?; + Ok(SubgroupSender { stream, codec: SubgroupCodec::new(header) }) + } + + /// Open a fetch stream for request `request_id` (§11.4); its Group IDs run + /// in `order`, the request's Group Order. + pub async fn open_fetch(&self, request_id: u64, order: GroupOrder) -> MoqResult { + let mut stream = self.open_uni().await?; + let mut b = Vec::new(); + FetchHeader { request_id }.encode(&mut b); + stream.write_all(&b).await.map_err(|e| transport("fetch header")(e.to_string()))?; + Ok(FetchSender { stream, codec: FetchCodec::new(order) }) + } + + /// Send a padding stream of `n` zero bytes (§11.5.1). + pub async fn send_padding(&self, n: usize) -> MoqResult<()> { + let mut s = self.open_uni().await?; + s.write_all(&encode_padding_stream(n)).await.map_err(|e| transport("padding")(e.to_string()))?; + s.finish().map_err(|e| transport("padding finish")(e.to_string())) + } + + /// Accept the peer's next data stream and read its header. A fetch stream is + /// read in Ascending order until [`DataReceiver::set_group_order`] says + /// otherwise. + pub async fn accept_data(&self) -> MoqResult { + let recv = self.accept_uni().await?; + DataReceiver::start(recv, GroupOrder::Ascending).await + } + + /// Send one object as a datagram (§11.2). QUIC datagrams are unreliable: + /// one larger than the path allows is refused here. + pub fn send_datagram(&self, d: &ObjectDatagram) -> MoqResult<()> { + self.connection.send_datagram(Bytes::from(d.encode()?)).map_err(|e| transport("send datagram")(e.to_string())) + } + + /// Receive the next datagram. + pub async fn recv_datagram(&self) -> MoqResult { + let b = self.connection.read_datagram().await.map_err(|e| transport("read datagram")(e.to_string()))?; + Ok(Datagram::decode(&b)?) + } + + /// Open a unidirectional stream (raw bytes; MOQT streams use the helpers above). pub async fn open_uni(&self) -> MoqResult { - self.connection - .open_uni() - .await - .map_err(|e| MoqError::Transport(format!("open_uni: {}", e))) + self.connection.open_uni().await.map_err(|e| MoqError::Transport(format!("open_uni: {}", e))) } - /// Accept a unidirectional stream from the peer. + /// Accept a unidirectional stream from the peer (raw bytes). pub async fn accept_uni(&self) -> MoqResult { - self.connection - .accept_uni() - .await - .map_err(|e| MoqError::Transport(format!("accept_uni: {}", e))) + self.connection.accept_uni().await.map_err(|e| MoqError::Transport(format!("accept_uni: {}", e))) } pub fn close(&self, code: u32, reason: &[u8]) { @@ -199,255 +287,215 @@ impl QuicSession { } } -/// Bidirectional control-plane stream. -/// -/// Frame format on the wire is identical to [`ControlMessage::encode`]: -/// `[varint tag][varint length][body]`. -pub struct ControlStream { +/// This endpoint's control stream, after its SETUP: GOAWAY may follow (§9.2). +pub struct ControlSender { + pub send: SendStream, +} + +impl ControlSender { + pub async fn send_goaway(&mut self, g: &Goaway) -> MoqResult<()> { + let b = ControlMessage::Goaway(g.clone()).encode()?; + self.send.write_all(&b).await.map_err(|e| transport("goaway write")(e.to_string())) + } +} + +/// The peer's control stream, its SETUP read. +pub struct ControlReceiver { + rx: DataReceiver, + pub peer_setup: Setup, +} + +impl ControlReceiver { + /// The next message on the peer's control stream (a GOAWAY); `None` at its + /// end, which §6.3 makes a protocol violation for the caller to act on. + pub async fn next_message(&mut self) -> MoqResult> { + match self.rx.next_event().await? { + Some(UniEvent::Control(m)) => Ok(Some(m)), + Some(_) => Err(MoqError::Transport("a control stream carried something other than a message".into())), + None => Ok(None), + } + } +} + +/// Read one framed control message from a stream: `None` at a FIN on a message +/// boundary. +async fn read_control_message(recv: &mut RecvStream) -> MoqResult> { + let mut first = [0u8; 1]; + match recv.read(&mut first).await.map_err(|e| transport("control read")(e.to_string()))? { + None | Some(0) => return Ok(None), + Some(_) => {} + } + let tlen = crate::draft22::varint::len_from_first(first[0]); + let mut head = vec![0u8; tlen + 2]; + head[0] = first[0]; + recv.read_exact(&mut head[1..]).await.map_err(|e| transport("control read")(e.to_string()))?; + let len = u16::from_be_bytes([head[tlen], head[tlen + 1]]) as usize; + let mut msg = head; + msg.resize(tlen + 2 + len, 0); + recv.read_exact(&mut msg[tlen + 2..]).await.map_err(|e| transport("control read")(e.to_string()))?; + Ok(Some(ControlMessage::decode_exact(&msg)?)) +} + +/// One request stream: both directions, and the rules of what may cross them. +pub struct QuicRequestStream { pub send: SendStream, pub recv: RecvStream, + pub state: RequestStream, } -impl ControlStream { - /// Send a control message. quinn buffers writes; this also flushes so - /// the peer can read what we wrote without needing the stream to close. - pub async fn send_message(&mut self, msg: &ControlMessage) -> MoqResult<()> { - let bytes = msg.encode()?; - self.send - .write_all(&bytes) - .await - .map_err(|e| MoqError::Transport(format!("control write: {}", e)))?; - Ok(()) +impl QuicRequestStream { + /// Send a message on the stream, refused where the peer would refuse it. + pub async fn send(&mut self, m: &ControlMessage) -> MoqResult<()> { + self.state.on_send(m)?; + self.send.write_all(&m.encode()?).await.map_err(|e| transport("request write")(e.to_string())) } - /// Flush any pending writes. Useful before awaiting recv from a peer - /// that hasn't responded yet. - pub async fn flush(&mut self) -> MoqResult<()> { - // quinn::SendStream uses internal buffering only via write_all; the - // bytes are queued for the connection driver. There is no public - // explicit flush; data is delivered as the driver runs. - Ok(()) + /// The peer's next message, checked against the stream's rules; `None` at + /// the peer's FIN. + pub async fn recv(&mut self) -> MoqResult> { + let m = read_control_message(&mut self.recv).await?; + if let Some(m) = &m { + self.state.on_receive(m)?; + } + Ok(m) } - /// Receive the next control message. - pub async fn recv_message(&mut self) -> MoqResult { - // Read the tag (varint), then the length (varint), then the body. - // We can't peek varints, so accumulate bytes incrementally. - let mut buf = BytesMut::new(); - // Read enough to decode the first varint (max 8 bytes) and the length. - // Simplification: read varint header byte-by-byte by reading 1, then - // decoding to know how many more we need. - let header_byte = read_one_byte(&mut self.recv).await?; - buf.put_u8(header_byte); - let needed = (1usize << ((header_byte >> 6) as usize)) - 1; - if needed > 0 { - let mut more = vec![0u8; needed]; - self.recv - .read_exact(&mut more) - .await - .map_err(|e| MoqError::Transport(format!("control read tag: {}", e)))?; - buf.put_slice(&more); - } - // Now read the length varint. - let len_first = read_one_byte(&mut self.recv).await?; - buf.put_u8(len_first); - let len_more_count = (1usize << ((len_first >> 6) as usize)) - 1; - if len_more_count > 0 { - let mut more = vec![0u8; len_more_count]; - self.recv - .read_exact(&mut more) - .await - .map_err(|e| MoqError::Transport(format!("control read len: {}", e)))?; - buf.put_slice(&more); - } - // Decode tag + length to learn how big the body is. - let mut peek = buf.clone().freeze(); - let _tag = decode_varint(&mut peek)?; - let body_len = decode_varint(&mut peek)? as usize; - if body_len > MAX_CONTROL_MESSAGE_BYTES { - return Err(MoqError::WireDecode(format!( - "control body too large: {}", - body_len - ))); - } - // Read body bytes. - let mut body = vec![0u8; body_len]; - if body_len > 0 { - self.recv - .read_exact(&mut body) - .await - .map_err(|e| MoqError::Transport(format!("control read body: {}", e)))?; - } - buf.put_slice(&body); - let mut full = buf.freeze(); - ControlMessage::decode(&mut full) - } - - /// Close the stream. - pub async fn finish(mut self) -> MoqResult<()> { - self.send - .finish() - .map_err(|e| MoqError::Transport(format!("finish: {}", e)))?; - Ok(()) + /// Send a FIN on this endpoint's direction (§6.4.2.2). + pub fn finish(&mut self) -> MoqResult<()> { + self.send.finish().map_err(|e| transport("finish")(e.to_string())) } } -/// Encode an [`ObjectHeader`] + optional [`ObjectReceipt`] + payload onto a -/// unidirectional QUIC stream. -/// -/// On-wire layout: -/// `[varint header_len][header CBOR][varint receipt_len][receipt CBOR or 0][payload]` -pub async fn write_object_to_uni( - stream: &mut SendStream, - header: &ObjectHeader, - receipt: Option<&ObjectReceipt>, - payload: &[u8], -) -> MoqResult<()> { - let header_bytes = serde_json::to_vec(header) - .map_err(|e| MoqError::WireEncode(format!("header: {}", e)))?; - let receipt_bytes = match receipt { - Some(r) => serde_json::to_vec(r) - .map_err(|e| MoqError::WireEncode(format!("receipt: {}", e)))?, - None => Vec::new(), +/// A subgroup stream this endpoint sends. +pub struct SubgroupSender { + pub stream: SendStream, + codec: SubgroupCodec, +} + +impl SubgroupSender { + pub async fn send(&mut self, o: &SubgroupObject) -> MoqResult<()> { + let mut b = Vec::new(); + self.codec.encode_object(o, &mut b)?; + self.stream.write_all(&b).await.map_err(|e| transport("object write")(e.to_string())) + } + + /// Close the subgroup with a FIN (§11.3.2). + pub fn finish(mut self) -> MoqResult<()> { + self.stream.finish().map_err(|e| transport("finish")(e.to_string())) + } +} + +/// A fetch stream this endpoint sends. +pub struct FetchSender { + pub stream: SendStream, + codec: FetchCodec, +} + +impl FetchSender { + pub async fn send(&mut self, e: &FetchEntry) -> MoqResult<()> { + let mut b = Vec::new(); + self.codec.encode(e, &mut b)?; + self.stream.write_all(&b).await.map_err(|e| transport("fetch write")(e.to_string())) + } + + pub fn finish(mut self) -> MoqResult<()> { + self.stream.finish().map_err(|e| transport("finish")(e.to_string())) + } +} + +/// A unidirectional stream the peer opened, read as its bytes arrive. +pub struct DataReceiver { + recv: RecvStream, + decoder: UniStreamDecoder, + finished: bool, + /// What the stream began with. + pub header: StreamHeader, +} + +impl DataReceiver { + async fn start(recv: RecvStream, order: GroupOrder) -> MoqResult { + let limits = Limits { max_payload: MAX_OBJECT_BYTES as u64, ..Limits::default() }; + let mut rx = DataReceiver { recv, decoder: UniStreamDecoder::new(order, limits), finished: false, header: StreamHeader::Padding }; + match rx.next_event().await? { + Some(UniEvent::Header(h)) => { + rx.header = h; + Ok(rx) + } + _ => Err(MoqError::Transport("a unidirectional stream ended before its type".into())), + } + } + + /// The Group Order of the request a fetch stream answers (§9.20.8). + pub fn set_group_order(&mut self, order: GroupOrder) { + self.decoder.set_group_order(order); + } + + /// The next item on the stream; `None` at its FIN. A FIN inside an item is + /// refused (§11.3). + pub async fn next_event(&mut self) -> MoqResult> { + loop { + if let Some(e) = self.decoder.next()? { + return Ok(Some(e)); + } + if self.finished { + self.decoder.finish()?; + return Ok(None); + } + match self.recv.read_chunk(64 * 1024, true).await.map_err(|e| transport("data read")(e.to_string()))? { + Some(c) => self.decoder.push(&c.bytes), + None => self.finished = true, + } + } + } + + /// The next object of a subgroup stream, as this crate's header, payload and + /// extras ([`crate::carriage`]); `None` at the FIN. + pub async fn next_object(&mut self) -> MoqResult> { + let StreamHeader::Subgroup(h) = self.header else { + return Err(MoqError::Transport("not a subgroup stream".into())); + }; + match self.next_event().await? { + Some(UniEvent::SubgroupObject(o)) => Ok(Some(from_subgroup_object(&h, &o)?)), + Some(_) => Err(MoqError::Transport("a subgroup stream carried something other than an object".into())), + None => Ok(None), + } + } + + /// The bundle a receipts-track subgroup carries (moq-streaming-format §4.1): + /// its one object, read to the FIN. + pub async fn read_receipt_bundle(&mut self) -> MoqResult { + let o = match self.next_event().await? { + Some(UniEvent::SubgroupObject(o)) => o, + _ => return Err(MoqError::Transport("a receipts-track stream without its object".into())), + }; + if self.next_event().await?.is_some() { + return Err(MoqError::Transport("a receipts-track group carries one object".into())); + } + ReceiptBundle::from_payload(&o.payload) + } +} + +/// Send one of this crate's objects on a subgroup stream. +pub async fn send_object(sender: &mut SubgroupSender, header: &ObjectHeader, payload: &[u8], extras: &ObjectExtras) -> MoqResult<()> { + sender.send(&to_subgroup_object(header, payload, extras)?).await +} + +/// Send a group's receipts as the one object of group `group_id` on the +/// receipts track `receipts_alias`, on a subgroup stream of its own, at its +/// media track's priority (moq-streaming-format §4.1). +pub async fn send_receipt_bundle(session: &QuicSession, receipts_alias: u64, group_id: u64, priority: u8, bundle: &ReceiptBundle) -> MoqResult<()> { + let header = SubgroupHeader { + track_alias: receipts_alias, + group_id, + subgroup_id: SubgroupId::Zero, + publisher_priority: Some(priority), + properties: false, + end_of_group: true, + first_object: true, }; - let mut framing = BytesMut::new(); - encode_varint(header_bytes.len() as u64, &mut framing)?; - framing.put_slice(&header_bytes); - encode_varint(receipt_bytes.len() as u64, &mut framing)?; - framing.put_slice(&receipt_bytes); - encode_varint(payload.len() as u64, &mut framing)?; - stream - .write_all(&framing) - .await - .map_err(|e| MoqError::Transport(format!("object framing: {}", e)))?; - if !payload.is_empty() { - stream - .write_all(payload) - .await - .map_err(|e| MoqError::Transport(format!("object payload: {}", e)))?; - } - Ok(()) -} - -/// Read an object delivery from a unidirectional stream. -pub async fn read_object_from_uni( - stream: &mut RecvStream, -) -> MoqResult<(ObjectHeader, Option, Bytes)> { - let header_len = read_varint(stream).await? as usize; - if header_len > MAX_CONTROL_MESSAGE_BYTES { - return Err(MoqError::WireDecode(format!( - "object header too large: {}", - header_len - ))); - } - let mut header_buf = vec![0u8; header_len]; - stream - .read_exact(&mut header_buf) - .await - .map_err(|e| MoqError::Transport(format!("read header: {}", e)))?; - let header: ObjectHeader = serde_json::from_slice(&header_buf) - .map_err(|e| MoqError::WireDecode(format!("header: {}", e)))?; - - let receipt_len = read_varint(stream).await? as usize; - let receipt = if receipt_len == 0 { - None - } else { - if receipt_len > MAX_CONTROL_MESSAGE_BYTES { - return Err(MoqError::WireDecode(format!( - "receipt too large: {}", - receipt_len - ))); - } - let mut receipt_buf = vec![0u8; receipt_len]; - stream - .read_exact(&mut receipt_buf) - .await - .map_err(|e| MoqError::Transport(format!("read receipt: {}", e)))?; - let r: ObjectReceipt = serde_json::from_slice(&receipt_buf) - .map_err(|e| MoqError::WireDecode(format!("receipt: {}", e)))?; - Some(r) - }; - - let payload_len = read_varint(stream).await? as usize; - if payload_len > MAX_OBJECT_BYTES { - return Err(MoqError::WireDecode(format!( - "payload too large: {}", - payload_len - ))); - } - let mut payload = vec![0u8; payload_len]; - if payload_len > 0 { - stream - .read_exact(&mut payload) - .await - .map_err(|e| MoqError::Transport(format!("read payload: {}", e)))?; - } - Ok((header, receipt, Bytes::from(payload))) -} - -/// Write a signed [`GroupReceipt`] to a unidirectional stream (length-prefixed -/// JSON). The publisher sends this so the subscriber holds the signed Merkle -/// root before the objects whose per-object inclusion proofs reference it — the -/// control-plane carrier for the data plane's receipt anchoring. -pub async fn write_group_receipt_to_uni( - stream: &mut SendStream, - receipt: &GroupReceipt, -) -> MoqResult<()> { - let bytes = serde_json::to_vec(receipt) - .map_err(|e| MoqError::WireEncode(format!("group receipt: {}", e)))?; - let mut framing = BytesMut::new(); - encode_varint(bytes.len() as u64, &mut framing)?; - framing.put_slice(&bytes); - stream - .write_all(&framing) - .await - .map_err(|e| MoqError::Transport(format!("group receipt framing: {}", e)))?; - Ok(()) -} - -/// Read a signed [`GroupReceipt`] from a unidirectional stream. -pub async fn read_group_receipt_from_uni(stream: &mut RecvStream) -> MoqResult { - let len = read_varint(stream).await? as usize; - if len > MAX_CONTROL_MESSAGE_BYTES { - return Err(MoqError::WireDecode(format!( - "group receipt too large: {}", - len - ))); - } - let mut buf = vec![0u8; len]; - stream - .read_exact(&mut buf) - .await - .map_err(|e| MoqError::Transport(format!("read group receipt: {}", e)))?; - serde_json::from_slice(&buf).map_err(|e| MoqError::WireDecode(format!("group receipt: {}", e))) -} - -// ── Helpers ── - -async fn read_one_byte(stream: &mut RecvStream) -> MoqResult { - let mut byte = [0u8; 1]; - stream - .read_exact(&mut byte) - .await - .map_err(|e| MoqError::Transport(format!("read 1 byte: {}", e)))?; - Ok(byte[0]) -} - -async fn read_varint(stream: &mut RecvStream) -> MoqResult { - let first = read_one_byte(stream).await?; - let len = 1usize << (first >> 6); - let mut buf = BytesMut::with_capacity(len); - buf.put_u8(first); - if len > 1 { - let mut rest = vec![0u8; len - 1]; - stream - .read_exact(&mut rest) - .await - .map_err(|e| MoqError::Transport(format!("read varint: {}", e)))?; - buf.put_slice(&rest); - } - let mut bytes = buf.freeze(); - decode_varint(&mut bytes) + let mut s = session.open_subgroup(header).await?; + s.send(&SubgroupObject::normal(0, bundle.to_payload()?)).await?; + s.finish() } /// Generate a self-signed certificate suitable for development and tests. @@ -549,82 +597,92 @@ impl QuicEndpoint { #[cfg(test)] mod tests { use super::*; - use crate::messages::{ControlMessage, SetupMessage}; + use crate::draft22::{DatagramContent, Location, Parameter, Parameters, RangeEnd, SubscribeOk, TrackRequest}; + use crate::draft22::kvp::Properties; + use crate::draft22::data::FetchObject; + use crate::draft22::Perspective; + use crate::session::{implementation_name, MoqSession, SessionRole}; + use crate::track::{TrackName, TrackNamespace}; use crate::MOQT_DRAFT_VERSION; use std::net::{IpAddr, Ipv4Addr}; + fn loopback() -> SocketAddr { + (IpAddr::V4(Ipv4Addr::LOCALHOST), 0).into() + } + + /// The -22 handshake over real QUIC: the ALPN selects -22 on both ends; each + /// endpoint opens its control stream with a SETUP; the server answers the + /// receipt extension the client declared; then a SUBSCRIBE on a request stream + /// of its own is answered there by SUBSCRIBE_OK. #[tokio::test] async fn quic_loopback_setup_handshake() { - let (chain, key) = - generate_self_signed(vec!["localhost".into()]).expect("self-signed"); - let server_addr: SocketAddr = (IpAddr::V4(Ipv4Addr::LOCALHOST), 0).into(); - let server = QuicEndpoint::server(server_addr, chain.clone(), key).expect("server"); + let (chain, key) = generate_self_signed(vec!["localhost".into()]).expect("self-signed"); + let server = QuicEndpoint::server(loopback(), chain.clone(), key).expect("server"); let bound = server.local_addr().unwrap(); - let chain_for_server = chain.clone(); let server_task = async move { let session = server.accept().await.expect("accept"); - // Briefly yield so QUIC driver can complete handshake before we - // accept the bidirectional stream the client opens. - let mut ctrl = session.accept_control_stream().await.expect("accept ctrl"); - let msg = ctrl.recv_message().await.expect("recv setup"); - let s = match msg { - ControlMessage::Setup(s) => s, - other => panic!("expected Setup, got {:?}", other), - }; - assert_eq!(s.versions, vec![MOQT_DRAFT_VERSION]); - ctrl.send_message(&ControlMessage::Setup(SetupMessage { - versions: vec![MOQT_DRAFT_VERSION], - role: 0x03, - jwp_receipts: s.jwp_receipts, - peer_label: Some("server".into()), - })) - .await - .expect("send response"); + assert_eq!(session.negotiated_draft(), Some(Draft::D22)); + let ctrl = session.accept_control().await.expect("accept control"); + let mut srv = MoqSession::new(SessionRole::PubSub); + let reply = srv.accept_setup(&ctrl.peer_setup).expect("client setup"); + assert!(srv.jwp_receipts_enabled); + assert_eq!(srv.negotiated_version, Some(MOQT_DRAFT_VERSION)); + let _ctl_out = session.open_control(&reply).await.expect("open control"); + let (mut req, first) = session.accept_request(0, 0).await.expect("accept request"); + let ControlMessage::Subscribe(sub) = first else { panic!("expected SUBSCRIBE") }; + srv.accept_peer_request_id(sub.request_id).expect("a client Request ID"); + assert_eq!(sub.name, TrackName::from_str("live")); + req.send(&ControlMessage::SubscribeOk(SubscribeOk { track_alias: 7, params: Parameters::new(), track_properties: Properties::new() })) + .await + .expect("send SUBSCRIBE_OK"); // Hold the session alive long enough for the client to read. tokio::time::sleep(std::time::Duration::from_millis(200)).await; drop(session); - let _ = chain_for_server; }; let client_task = async move { - let client_addr: SocketAddr = (IpAddr::V4(Ipv4Addr::LOCALHOST), 0).into(); - let client = QuicEndpoint::client(client_addr, chain).expect("client"); + let client = QuicEndpoint::client(loopback(), chain).expect("client"); let session = client.connect(bound, "localhost").await.expect("connect"); - let mut ctrl = session.open_control_stream().await.expect("open ctrl"); - ctrl.send_message(&ControlMessage::Setup(SetupMessage { - versions: vec![MOQT_DRAFT_VERSION], - role: 0x02, - jwp_receipts: true, - peer_label: Some("client".into()), - })) - .await - .expect("send setup"); - let response = ctrl.recv_message().await.expect("recv response"); - match response { - ControlMessage::Setup(s) => { - assert!(s.jwp_receipts); - assert_eq!(s.peer_label.as_deref(), Some("server")); - } + assert_eq!(session.negotiated_draft(), Some(Draft::D22)); + let mut me = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client); + let _ctl_out = session.open_control(&me.build_setup(true)).await.expect("open control"); + let ctrl = session.accept_control().await.expect("accept control"); + me.on_peer_setup(&ctrl.peer_setup, true).expect("server setup"); + assert!(me.jwp_receipts_enabled); + assert_eq!(ctrl.peer_setup.options.implementation(), Some(implementation_name().as_bytes())); + let id = me.next_request_id().unwrap(); + let sub = ControlMessage::Subscribe(TrackRequest { + request_id: id, + namespace: TrackNamespace::from_strs(["tv", "transaction.science"]), + name: TrackName::from_str("live"), + params: Parameters::new().with(Parameter::LocationFilter(crate::draft22::LocationFilter::NextObject)), + }); + let mut req = session.open_request(&sub, 0, 0).await.expect("open request"); + match req.recv().await.expect("recv").expect("a response") { + ControlMessage::SubscribeOk(ok) => assert_eq!(ok.track_alias, 7), other => panic!("unexpected: {:?}", other), } + assert!(req.state.accepted()); client.wait_idle().await; }; tokio::join!(server_task, client_task); } + /// Signed, receipt-bearing objects over real QUIC on -22's data plane: the + /// group's receipts ride its receipts track (moq-streaming-format §4.1), one + /// subgroup stream; the objects ride the media track, another, each with its + /// receipt in its properties. The subscriber trusts only the publisher's key. #[tokio::test] async fn quic_delivers_signed_receipt_objects_end_to_end() { - use crate::messages::ObjectHeader; use crate::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier}; use crate::track::{GroupId, ObjectStatus, TrackAlias}; use ed25519_dalek::SigningKey; use rand_core_06::OsRng; let (chain, key) = generate_self_signed(vec!["localhost".into()]).expect("self-signed"); - let server_addr: SocketAddr = (IpAddr::V4(Ipv4Addr::LOCALHOST), 0).into(); - let server = QuicEndpoint::server(server_addr, chain.clone(), key).expect("server"); + let server = QuicEndpoint::server(loopback(), chain.clone(), key).expect("server"); let bound = server.local_addr().unwrap(); // The publisher's identity — the key its group receipt is signed under @@ -633,6 +691,7 @@ mod tests { let publisher_key = signing_key.verifying_key(); let track_alias = TrackAlias(7); + let receipts_alias = 8u64; let group_id = GroupId(1); let origin = "did:key:zPublisherOfTruth"; let frames = [ @@ -655,15 +714,12 @@ mod tests { let id = builder.add_object(hash, *joules, origin, now); pending.push((id, payload.clone(), hash, *joules)); } - let proofs: Vec<_> = - (0..pending.len()).map(|i| builder.proof_for(i).unwrap()).collect(); + let proofs: Vec<_> = (0..pending.len()).map(|i| builder.proof_for(i).unwrap()).collect(); let group_end = chrono::Utc::now().to_rfc3339(); let group_receipt = builder.finalize(&signing_key, "pub-1", group_end); - // One uni stream carries the signed group root, then each object with - // its per-object inclusion proof — deterministic ordering. - let mut uni = session.open_uni().await.expect("open_uni"); - write_group_receipt_to_uni(&mut uni, &group_receipt).await.expect("send group receipt"); + let mut receipts = Vec::new(); + let mut objects = Vec::new(); for ((id, payload, hash, joules), proof) in pending.into_iter().zip(proofs) { let header = ObjectHeader { track_alias, @@ -686,50 +742,70 @@ mod tests { merkle_proof: proof, energy_class: None, }; - write_object_to_uni(&mut uni, &header, Some(&receipt), &payload) - .await - .expect("send object"); + receipts.push(receipt.clone()); + objects.push((header, payload, receipt)); } - uni.finish().expect("finish uni"); - // Hold the connection open until the subscriber has drained the stream. + // The receipts first, no later than the group's first object (§4.1). + let bundle = ReceiptBundle { group: group_receipt.clone(), objects: receipts }; + send_receipt_bundle(&session, receipts_alias, group_id.0, 128, &bundle).await.expect("send receipts"); + 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 + .expect("open subgroup"); + for (header, payload, receipt) in objects { + let extras = ObjectExtras { receipt: Some(receipt), wai_receipt: None, delivery_timeout_ms: None }; + send_object(&mut media, &header, &payload, &extras).await.expect("send object"); + } + media.finish().expect("finish media"); + // Hold the connection open until the subscriber has drained the streams. tokio::time::sleep(std::time::Duration::from_millis(300)).await; drop(session); }; // ── Subscriber (client side) ── let subscriber = async move { - let client_addr: SocketAddr = (IpAddr::V4(Ipv4Addr::LOCALHOST), 0).into(); - let client = QuicEndpoint::client(client_addr, chain).expect("client"); + let client = QuicEndpoint::client(loopback(), chain).expect("client"); let session = client.connect(bound, "localhost").await.expect("connect"); - let mut uni = session.accept_uni().await.expect("accept_uni"); - let group_receipt = - read_group_receipt_from_uni(&mut uni).await.expect("read group receipt"); + let mut bundle = None; + let mut recovered = Vec::new(); + for _ in 0..2 { + let mut rx = session.accept_data().await.expect("accept data"); + let StreamHeader::Subgroup(h) = rx.header else { panic!("a subgroup stream") }; + if h.track_alias == receipts_alias { + bundle = Some(rx.read_receipt_bundle().await.expect("receipts")); + } else { + assert_eq!(h.track_alias, track_alias.0); + while let Some((header, payload, extras)) = rx.next_object().await.expect("object") { + recovered.push((header, payload, extras.receipt.expect("every object carries a receipt by construction"))); + } + } + } + let bundle = bundle.expect("the receipts track arrived"); + let group_receipt = bundle.group; // Trust only the real publisher; the signed root must verify. let verifier = ReceiptVerifier::with_anchors(vec![publisher_key]); - let root = verifier - .verify_group(&group_receipt) - .expect("group signature verifies against the trusted publisher anchor"); + let root = verifier.verify_group(&group_receipt).expect("group signature verifies against the trusted publisher anchor"); assert_eq!(group_receipt.leaf_count, frame_count); + assert_eq!(bundle.objects.len() as u64, frame_count); - let mut recovered = Vec::new(); - for _ in 0..group_receipt.leaf_count { - let (_header, receipt, payload) = - read_object_from_uni(&mut uni).await.expect("read object"); - let receipt = receipt.expect("every object carries a receipt by construction"); - verifier - .verify_payload(&payload, &receipt, &root) - .expect("object payload + inclusion proof verify against the signed root"); - recovered.push(payload); + for (header, payload, receipt) in &recovered { + assert_eq!(receipt.object_id, header.object_id); + assert_eq!(bundle.objects[header.object_id.0 as usize], *receipt, "the property and the receipts track agree"); + verifier.verify_payload(payload, receipt, &root).expect("object payload + inclusion proof verify against the signed root"); } assert_eq!( - recovered, - vec![ - Bytes::from_static(b"frame-one"), - Bytes::from_static(b"frame-two"), - Bytes::from_static(b"frame-three"), - ], + recovered.iter().map(|r| r.1.clone()).collect::>(), + vec![Bytes::from_static(b"frame-one"), Bytes::from_static(b"frame-two"), Bytes::from_static(b"frame-three")], "payloads recovered intact over real QUIC" ); @@ -737,14 +813,131 @@ mod tests { // only as good as the key it is signed under. let stranger = SigningKey::generate(&mut OsRng).verifying_key(); let untrusted = ReceiptVerifier::with_anchors(vec![stranger]); - assert!( - untrusted.verify_group(&group_receipt).is_err(), - "a group signed by an untrusted publisher must be rejected" - ); + assert!(untrusted.verify_group(&group_receipt).is_err(), "a group signed by an untrusted publisher must be rejected"); client.wait_idle().await; }; tokio::join!(publisher, subscriber); } + + /// The rest of -22's data plane over real QUIC: an object whose `WAI_RECEIPT` + /// property (moq-streaming-format §5) agrees with its receipt, an object + /// datagram, a fetch stream in Descending order with an End of Range, and a + /// padding stream. + #[tokio::test] + async fn quic_carries_the_wai_receipt_property_datagrams_and_fetch() { + use crate::carriage::WaiReceiptProperty; + use crate::receipt::{ObjectReceipt, ReceiptBuilder}; + use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias}; + use ed25519_dalek::SigningKey; + + let (chain, key) = generate_self_signed(vec!["localhost".into()]).expect("self-signed"); + let server = QuicEndpoint::server(loopback(), chain.clone(), key).expect("server"); + let bound = server.local_addr().unwrap(); + let signing_key = SigningKey::from_bytes(&[9; 32]); + let envelope = Bytes::from_static(b"WAI1-envelope-bytes"); + let hash = ObjectReceipt::hash_payload(&envelope); + let mut b = ReceiptBuilder::new(TrackAlias(3), GroupId(0)); + b.add_labelled_object(hash, 0, "o", "t", vec![0]).unwrap(); + let receipt = ObjectReceipt { + track_alias: TrackAlias(3), + group_id: GroupId(0), + object_id: ObjectId(0), + content_hash: hash, + joules_micro: 0, + origin: "o".into(), + merkle_proof: b.proof_for(0).unwrap(), + energy_class: Some(vec![0]), + }; + let group = b.finalize_labelled(&signing_key, "p", "t", None).unwrap(); + let property = WaiReceiptProperty::from_receipts(&receipt, &group).expect("an unmetered property"); + let fetch = vec![ + FetchEntry::Object(FetchObject { group_id: 9, object_id: 0, subgroup_id: Some(0), publisher_priority: 5, properties: None, payload: b"g9".to_vec() }), + FetchEntry::EndOfRange { kind: RangeEnd::NonExistent, location: Location::new(7, 4) }, + FetchEntry::Object(FetchObject { group_id: 6, object_id: 0, subgroup_id: None, publisher_priority: 5, properties: None, payload: b"g6".to_vec() }), + ]; + let datagram = ObjectDatagram { + track_alias: 4, + group_id: 2, + object_id: 1, + publisher_priority: Some(3), + properties: None, + end_of_group: true, + content: DatagramContent::Payload(b"dg".to_vec()), + }; + + let (p2, f2, d2, r2) = (property.clone(), fetch.clone(), datagram.clone(), receipt.clone()); + let publisher = async move { + let session = server.accept().await.expect("accept"); + let header = ObjectHeader { + track_alias: TrackAlias(3), + group_id: GroupId(0), + object_id: ObjectId(0), + publisher_priority: 1, + status: ObjectStatus::Normal, + payload_len: envelope.len() as u64, + mrl_layer: 0, + mrl_total_layers: 0, + wai_envelope: true, + }; + let mut s = session + .open_subgroup(SubgroupHeader { track_alias: 3, group_id: 0, subgroup_id: SubgroupId::Zero, publisher_priority: Some(1), properties: true, end_of_group: false, first_object: true }) + .await + .unwrap(); + send_object(&mut s, &header, &envelope, &ObjectExtras { receipt: Some(r2), wai_receipt: Some(p2), delivery_timeout_ms: Some(40) }).await.unwrap(); + s.finish().unwrap(); + let mut f = session.open_fetch(4, GroupOrder::Descending).await.unwrap(); + for e in &f2 { + f.send(e).await.unwrap(); + } + f.finish().unwrap(); + session.send_padding(16).await.unwrap(); + session.send_datagram(&d2).unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + drop(session); + }; + + let subscriber = async move { + let client = QuicEndpoint::client(loopback(), chain).expect("client"); + let session = client.connect(bound, "localhost").await.expect("connect"); + let mut seen = (false, false, false); + for _ in 0..3 { + let mut rx = session.accept_data().await.expect("accept data"); + match rx.header.clone() { + StreamHeader::Subgroup(_) => { + let (h, payload, extras) = rx.next_object().await.unwrap().expect("an object"); + assert!(h.wai_envelope); + assert_eq!(extras.delivery_timeout_ms, Some(40)); + let p = extras.wai_receipt.expect("the property"); + p.check().expect("a valid property"); + assert!(p.agrees_with(&extras.receipt.expect("its receipt"), &group)); + assert_eq!(ObjectReceipt::hash_payload(&payload), p.content_hash); + assert!(rx.next_object().await.unwrap().is_none()); + seen.0 = true; + } + StreamHeader::Fetch(fh) => { + assert_eq!(fh.request_id, 4); + rx.set_group_order(GroupOrder::Descending); + let mut got = Vec::new(); + while let Some(UniEvent::FetchEntry(e)) = rx.next_event().await.unwrap() { + got.push(e); + } + assert_eq!(got, fetch); + seen.1 = true; + } + StreamHeader::Padding => { + while rx.next_event().await.unwrap().is_some() {} + seen.2 = true; + } + StreamHeader::Control => panic!("no control stream in this test"), + } + } + assert_eq!(seen, (true, true, true)); + assert_eq!(session.recv_datagram().await.unwrap(), Datagram::Object(datagram)); + client.wait_idle().await; + }; + + tokio::join!(publisher, subscriber); + } } diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/wire.rs b/joule-code/joule-code-rs/crates/joule-moq/src/wire.rs index 52f430200..7bde7025d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/wire.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/wire.rs @@ -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(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(src: &mut B) -> MoqResult { 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(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(src: &mut B) -> MoqResult { - 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; } +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 { + 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] -- 2.54.0 (Apple Git-157) From 868bcd220364f8e628d59349cb27727c49bdc06e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:12:11 -0400 Subject: [PATCH 3/7] joule-moq: a draft-22 vector corpus, generated deterministically and held tests/vectors/draft-22/vectors.json: varints, every control message type, control, subgroup, fetch and padding streams, datagrams, and the WAI_RECEIPT property's value, each with its stated reading; and negative vectors (cut short, a varint past its frame, unknown types, repeated parameters, lengths past the draft's limits, flags it forbids, delta and ID overflows), each with the refusal and consequence it must meet. tests/draft22_vectors.rs generates it and holds the crate to it, whole and fed in pieces to the incremental decoder. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq/tests/draft22_vectors.rs | 768 ++++++ .../tests/vectors/draft-22/vectors.json | 2175 +++++++++++++++++ 2 files changed, 2943 insertions(+) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/vectors.json diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs new file mode 100644 index 000000000..d4361a032 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs @@ -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) -> Properties { + ps.sort_by_key(|k| k.ty); + Properties(ps) +} + +/// Properties with an Immutable Properties pair holding `inner`. +fn with_immutable(mut outer: Vec, inner: Vec) -> 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, ranges: &[(u64, Option)]) -> 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) -> Parameters { + let mut v = ps; + v.sort_by_key(|p| p.type_code()); + Parameters(v) +} + +struct Corpus { + vectors: Vec, +} + +impl Corpus { + fn push(&mut self, name: &str, kind: &str, bytes: &[u8], canonical: bool, context: Option, 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, order: GroupOrder, events: Vec) { + 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, canonical: bool) { + self.push(name, "datagram", &bytes, canonical, None, json::datagram(&d)); + } + + fn bad(&mut self, name: &str, kind: &str, bytes: &[u8], context: Option, 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 { + 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 { + varint::to_vec(x) +} + +fn cat(parts: &[&[u8]]) -> Vec { + parts.concat() +} + +fn wai_receipt(content: u8, joules: u64, class: &str, evidence: Vec, 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 { + 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 = (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, &[goaway.clone()]).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| -> (Vec, Vec) { + 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, p: u8, pr: Option, 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 { + 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 { + (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>), 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> { + let mut out = Vec::new(); + let mut sub: Option = None; + let mut fetch: Option = 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, 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 = 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"); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/vectors.json b/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/vectors.json new file mode 100644 index 000000000..d5840eacf --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/vectors.json @@ -0,0 +1,2175 @@ +{ + "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": [ + { + "canonical": true, + "expect": { + "length": 1, + "value": 37 + }, + "hex": "25", + "kind": "varint", + "name": "varint 25" + }, + { + "canonical": false, + "expect": { + "length": 2, + "value": 37 + }, + "hex": "8025", + "kind": "varint", + "name": "varint 8025" + }, + { + "canonical": true, + "expect": { + "length": 2, + "value": 15293 + }, + "hex": "bbbd", + "kind": "varint", + "name": "varint bbbd" + }, + { + "canonical": true, + "expect": { + "length": 4, + "value": 226442877 + }, + "hex": "ed7f3e7d", + "kind": "varint", + "name": "varint ed7f3e7d" + }, + { + "canonical": true, + "expect": { + "length": 6, + "value": 2893212287960 + }, + "hex": "faa1a0e403d8", + "kind": "varint", + "name": "varint faa1a0e403d8" + }, + { + "canonical": true, + "expect": { + "length": 7, + "value": 151288809941952 + }, + "hex": "fc8998abc66bc0", + "kind": "varint", + "name": "varint fc8998abc66bc0" + }, + { + "canonical": true, + "expect": { + "length": 8, + "value": 70423237261249041 + }, + "hex": "fefa318fa8e3ca11", + "kind": "varint", + "name": "varint fefa318fa8e3ca11" + }, + { + "canonical": true, + "expect": { + "length": 9, + "value": 18446744073709551615 + }, + "hex": "ffffffffffffffffff", + "kind": "varint", + "name": "varint ffffffffffffffffff" + }, + { + "canonical": false, + "expect": { + "length": 3, + "value": 0 + }, + "hex": "c00000", + "kind": "varint", + "name": "varint 0 in three bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "e001", + "kind": "varint", + "name": "varint cut short" + }, + { + "canonical": true, + "expect": { + "message": "SETUP", + "options": [ + { + "bytes": "2f6c6976653f726f6f6d3d31", + "type": 1 + }, + { + "bytes": "010100746f6b", + "type": 3 + }, + { + "type": 4, + "value": 1024 + }, + { + "bytes": "72656c61792e6578616d706c653a343433", + "type": 5 + }, + { + "type": 6, + "value": 4 + }, + { + "bytes": "6578616d706c652d696d706c2f312e30", + "type": 7 + }, + { + "type": 8, + "value": 2 + }, + { + "bytes": "677265617365", + "type": 157 + }, + { + "type": 14918, + "value": 2 + } + ] + }, + "hex": "af00004e010c2f6c6976653f726f6f6d3d310206010100746f6b018400011172656c61792e6578616d706c653a343433010401106578616d706c652d696d706c2f312e300102809506677265617365b9a902", + "kind": "control", + "name": "SETUP from a client" + }, + { + "canonical": true, + "expect": { + "message": "SETUP", + "options": [ + { + "bytes": "6578616d706c652d696d706c2f312e30", + "type": 7 + } + ] + }, + "hex": "af00001207106578616d706c652d696d706c2f312e30", + "kind": "control", + "name": "SETUP from a server" + }, + { + "canonical": true, + "expect": { + "message": "SETUP", + "options": [] + }, + "hex": "af000000", + "kind": "control", + "name": "SETUP with no options" + }, + { + "canonical": true, + "expect": { + "message": "GOAWAY", + "new_session_uri": "6d6f71743a2f2f72656c61792e6578616d706c652f6e657874", + "timeout_ms": 5000 + }, + "hex": "10001c196d6f71743a2f2f72656c61792e6578616d706c652f6e6578749388", + "kind": "control", + "name": "GOAWAY with a new session URI" + }, + { + "canonical": true, + "expect": { + "message": "GOAWAY", + "new_session_uri": "", + "timeout_ms": 0 + }, + "hex": "1000020000", + "kind": "control", + "name": "GOAWAY with no URI and no timeout" + }, + { + "canonical": true, + "expect": { + "message": "SUBSCRIBE", + "name": "766964656f", + "namespace": [ + "6578616d706c65", + "63616d31" + ], + "parameters": [ + { + "type": "OBJECT_DELIVERY_TIMEOUT", + "value": 250 + }, + { + "token": { + "alias_type": 3, + "token_type": 0, + "value": "6a7774" + }, + "type": "AUTHORIZATION_TOKEN" + }, + { + "type": "RENDEZVOUS_TIMEOUT", + "value": 1000 + }, + { + "type": "SUBGROUP_DELIVERY_TIMEOUT", + "value": 500 + }, + { + "type": "FORWARD", + "value": 1 + }, + { + "type": "SUBSCRIBER_PRIORITY", + "value": 10 + }, + { + "filter": { + "filter_type": 5 + }, + "type": "LOCATION_FILTER" + }, + { + "type": "GROUP_ORDER", + "value": 2 + }, + { + "parameters": [ + { + "type": "FILL_TIMEOUT", + "value": 100 + }, + { + "filter": { + "filter_type": 1, + "start_group": 1 + }, + "type": "LOCATION_FILTER" + } + ], + "type": "FILL_PARAMETERS" + }, + { + "filter": { + "ranges": [ + [ + 0, + 2 + ] + ], + "set_id": 0 + }, + "type": "SUBGROUP_FILTER" + }, + { + "filter": { + "ranges": [ + [ + 3, + 5 + ], + [ + 10, + null + ] + ], + "set_id": 0 + }, + "type": "OBJECTID_FILTER" + }, + { + "filter": { + "ranges": [ + [ + 0, + 127 + ] + ], + "set_id": 1 + }, + "type": "PRIORITY_FILTER" + }, + { + "filter": { + "property_type": 14912, + "ranges": [ + [ + 0, + 2 + ] + ], + "set_id": 0 + }, + "type": "OBJECT_PROPERTY_FILTER" + }, + { + "type": "NEW_GROUP_REQUEST", + "value": 0 + }, + { + "type": "INCLUDE_PROPERTIES", + "value": 1 + } + ], + "request_id": 0 + }, + "hex": "0300500002076578616d706c650463616d3105766964656f0f0280fa010503006a77740183e80281f40a01100a0105010201050a641701010203000002010400030205010301007f010500ba4000020a000301", + "kind": "control", + "name": "SUBSCRIBE with every parameter" + }, + { + "canonical": true, + "expect": { + "message": "SUBSCRIBE", + "name": "74", + "namespace": [], + "parameters": [], + "request_id": 2 + }, + "hex": "0300050200017400", + "kind": "control", + "name": "SUBSCRIBE with no parameters, an empty namespace" + }, + { + "canonical": false, + "expect": { + "message": "SUBSCRIBE", + "name": "62", + "namespace": [ + "61" + ], + "parameters": [], + "request_id": 5 + }, + "hex": "030009c00005010161016200", + "kind": "control", + "name": "SUBSCRIBE whose Request ID takes three bytes" + }, + { + "canonical": true, + "expect": { + "message": "SUBSCRIBE_OK", + "parameters": [ + { + "type": "EXPIRES", + "value": 30000 + }, + { + "location": [ + 5, + 2 + ], + "type": "LARGEST_OBJECT" + } + ], + "track_alias": 7, + "track_properties": [ + { + "type": 2, + "value": 200 + }, + { + "type": 4, + "value": 10000 + }, + { + "bytes": "7805", + "type": 11 + }, + { + "type": 14, + "value": 64 + }, + { + "type": 34, + "value": 1 + }, + { + "type": 48, + "value": 1 + }, + { + "bytes": "78", + "type": 14917 + } + ] + }, + "hex": "04001d070208c075300105020280c802a71007027805034014010e01ba150178", + "kind": "control", + "name": "SUBSCRIBE_OK with track properties" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH_STATE_NOTIFY", + "parameters": [ + { + "location": [ + 9, + 0 + ], + "type": "LARGEST_OBJECT" + }, + { + "type": "FORWARD", + "value": 0 + }, + { + "filter": { + "filter_type": 2, + "start_group": 9, + "start_object": 0 + }, + "type": "LOCATION_FILTER" + } + ] + }, + "hex": "22000a03090900070011020900", + "kind": "control", + "name": "PUBLISH_STATE_NOTIFY" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH", + "name": "617564696f", + "namespace": [ + "6578616d706c65", + "63616d31" + ], + "parameters": [ + { + "type": "EXPIRES", + "value": 60000 + }, + { + "location": [ + 4, + 9 + ], + "type": "LARGEST_OBJECT" + }, + { + "type": "FORWARD", + "value": 1 + }, + { + "filter": { + "end_group_delta": 2, + "filter_type": 3, + "start_group": 4, + "start_object": 0 + }, + "type": "LOCATION_FILTER" + }, + { + "type": "GROUP_ORDER", + "value": 1 + } + ], + "request_id": 1, + "track_alias": 3, + "track_properties": [ + { + "type": 48, + "value": 0 + } + ] + }, + "hex": "1d00290102076578616d706c650463616d3105617564696f030508c0ea600104090701110304000201013000", + "kind": "control", + "name": "PUBLISH" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH_DONE", + "reason": "646f6e65", + "status": 2, + "stream_count": 3 + }, + "hex": "0b0007020304646f6e65", + "kind": "control", + "name": "PUBLISH_DONE" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH_DONE", + "reason": "", + "status": 5, + "stream_count": 18446744073709551615 + }, + "hex": "0b000b05ffffffffffffffffff00", + "kind": "control", + "name": "PUBLISH_DONE with a stream count of 2^64-1" + }, + { + "canonical": true, + "expect": { + "message": "FETCH", + "name": "766964656f", + "namespace": [ + "6578616d706c65", + "63616d31" + ], + "parameters": [ + { + "token": { + "alias": 1, + "alias_type": 2 + }, + "type": "AUTHORIZATION_TOKEN" + }, + { + "type": "FILL_TIMEOUT", + "value": 0 + }, + { + "type": "SUBSCRIBER_PRIORITY", + "value": 200 + }, + { + "filter": { + "end_group_delta": 3, + "end_object": 7, + "filter_type": 4, + "start_group": 1, + "start_object": 0 + }, + "type": "LOCATION_FILTER" + }, + { + "type": "GROUP_ORDER", + "value": 2 + }, + { + "type": "INCLUDE_PROPERTIES", + "value": 0 + } + ], + "request_id": 4 + }, + "hex": "1600280402076578616d706c650463616d3105766964656f0603020201070016c801040100030701021300", + "kind": "control", + "name": "FETCH" + }, + { + "canonical": true, + "expect": { + "end_location": [ + 4, + 7 + ], + "end_of_track": 1, + "message": "FETCH_OK", + "parameters": [], + "track_properties": [ + { + "type": 14, + "value": 128 + } + ] + }, + "hex": "180007010407000e8080", + "kind": "control", + "name": "FETCH_OK at the end of the track" + }, + { + "canonical": true, + "expect": { + "message": "TRACK_STATUS", + "name": "63616d31", + "namespace": [ + "6578616d706c65" + ], + "parameters": [ + { + "token": { + "alias": 2, + "alias_type": 1, + "token_type": 157, + "value": "616263" + }, + "type": "AUTHORIZATION_TOKEN" + }, + { + "type": "INCLUDE_PROPERTIES", + "value": 1 + } + ], + "request_id": 6 + }, + "hex": "0d001b0601076578616d706c650463616d310203070102809d6162633201", + "kind": "control", + "name": "TRACK_STATUS" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH_NAMESPACE", + "parameters": [ + { + "token": { + "alias_type": 3, + "token_type": 1, + "value": "dead" + }, + "type": "AUTHORIZATION_TOKEN" + } + ], + "prefix": [ + "6578616d706c65", + "63616d31" + ], + "request_id": 3 + }, + "hex": "0600160302076578616d706c650463616d310103040301dead", + "kind": "control", + "name": "PUBLISH_NAMESPACE" + }, + { + "canonical": true, + "expect": { + "message": "SUBSCRIBE_NAMESPACE", + "parameters": [], + "prefix": [ + "6578616d706c65" + ], + "request_id": 8 + }, + "hex": "50000b0801076578616d706c6500", + "kind": "control", + "name": "SUBSCRIBE_NAMESPACE" + }, + { + "canonical": true, + "expect": { + "message": "SUBSCRIBE_TRACKS", + "parameters": [ + { + "type": "FORWARD", + "value": 0 + }, + { + "type": "GROUP_ORDER", + "value": 1 + }, + { + "filter": { + "property_type": 48, + "ranges": [ + [ + 1, + 1 + ] + ], + "set_id": 0 + }, + "type": "TRACK_PROPERTY_FILTER" + }, + { + "type": "INCLUDE_PROPERTIES", + "value": 0 + } + ], + "prefix": [], + "request_id": 10 + }, + "hex": "51000f0a0004100012010704003001000c00", + "kind": "control", + "name": "SUBSCRIBE_TRACKS for every track" + }, + { + "canonical": true, + "expect": { + "message": "NAMESPACE", + "suffix": [ + "3432" + ] + }, + "hex": "08000401023432", + "kind": "control", + "name": "NAMESPACE" + }, + { + "canonical": true, + "expect": { + "message": "NAMESPACE_DONE", + "suffix": [ + "313233" + ] + }, + "hex": "0e00050103313233", + "kind": "control", + "name": "NAMESPACE_DONE" + }, + { + "canonical": true, + "expect": { + "message": "PUBLISH_SKIPPED", + "name": "766964656f", + "suffix": [ + "63616d32" + ] + }, + "hex": "0f000c010463616d3205766964656f", + "kind": "control", + "name": "PUBLISH_SKIPPED" + }, + { + "canonical": true, + "expect": { + "message": "REQUEST_UPDATE", + "parameters": [ + { + "type": "FORWARD", + "value": 0 + }, + { + "type": "SUBSCRIBER_PRIORITY", + "value": 5 + }, + { + "filter": { + "filter_type": 0 + }, + "type": "LOCATION_FILTER" + }, + { + "filter": null, + "type": "OBJECTID_FILTER" + }, + { + "type": "NEW_GROUP_REQUEST", + "value": 12 + } + ], + "request_id": 12 + }, + "hex": "02000c0c0510001005010005000c0c", + "kind": "control", + "name": "REQUEST_UPDATE for a subscription" + }, + { + "canonical": true, + "expect": { + "message": "REQUEST_UPDATE", + "parameters": [ + { + "namespace": [ + "6578616d706c65", + "62" + ], + "type": "TRACK_NAMESPACE_PREFIX" + } + ], + "request_id": 14 + }, + "hex": "02000e0e013402076578616d706c650162", + "kind": "control", + "name": "REQUEST_UPDATE for a namespace subscription" + }, + { + "canonical": true, + "expect": { + "message": "REQUEST_OK", + "parameters": [ + { + "location": [ + 3, + 9 + ], + "type": "LARGEST_OBJECT" + } + ], + "track_properties": [ + { + "type": 34, + "value": 2 + } + ] + }, + "hex": "070006010903092202", + "kind": "control", + "name": "REQUEST_OK answering TRACK_STATUS" + }, + { + "canonical": true, + "expect": { + "message": "REQUEST_OK", + "parameters": [ + { + "type": "EXPIRES", + "value": 1000 + } + ], + "track_properties": [] + }, + "hex": "070004010883e8", + "kind": "control", + "name": "REQUEST_OK answering PUBLISH" + }, + { + "canonical": true, + "expect": { + "code": 16, + "message": "REQUEST_ERROR", + "reason": "6e6f207375636820747261636b", + "redirect": null, + "retry_interval": 0 + }, + "hex": "05001010000d6e6f207375636820747261636b", + "kind": "control", + "name": "REQUEST_ERROR" + }, + { + "canonical": true, + "expect": { + "code": 52, + "message": "REQUEST_ERROR", + "reason": "", + "redirect": { + "connect_uri": "6d6f71743a2f2f6f746865722e6578616d706c652f6c697665", + "name": "63616d31", + "namespace": [ + "6578616d706c65" + ] + }, + "retry_interval": 1 + }, + "hex": "05002b340100196d6f71743a2f2f6f746865722e6578616d706c652f6c69766501076578616d706c650463616d31", + "kind": "control", + "name": "REQUEST_ERROR with a redirect" + }, + { + "canonical": true, + "expect": { + "code": 157, + "message": "REQUEST_ERROR", + "reason": "677265617365", + "redirect": null, + "retry_interval": 30001 + }, + "hex": "05000c809dc0753106677265617365", + "kind": "control", + "name": "REQUEST_ERROR with a code this draft does not register" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "header": "control" + }, + { + "control": { + "message": "SETUP", + "options": [ + { + "bytes": "6578616d706c652d696d706c2f312e30", + "type": 7 + } + ] + } + }, + { + "control": { + "message": "GOAWAY", + "new_session_uri": "", + "timeout_ms": 1000 + } + } + ], + "hex": "af00001207106578616d706c652d696d706c2f312e301000030083e8", + "kind": "uni-stream", + "name": "control stream: SETUP then GOAWAY" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "end_of_group": true, + "first_object": true, + "group_id": 0, + "header": "subgroup", + "properties": false, + "publisher_priority": 64, + "subgroup_id": null, + "subgroup_id_mode": 0, + "track_alias": 2 + }, + { + "object": { + "object_id": 0, + "payload": "6b6579", + "properties": [], + "status": 0 + } + }, + { + "object": { + "object_id": 1, + "payload": "64656c7461", + "properties": [], + "status": 0 + } + }, + { + "object": { + "object_id": 2, + "payload": "", + "properties": [], + "status": 3 + } + } + ], + "hex": "5802004000036b6579000564656c7461000003", + "kind": "uni-stream", + "name": "subgroup: Subgroup ID 0, a priority, End of Group" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "end_of_group": false, + "first_object": false, + "group_id": 7, + "header": "subgroup", + "properties": true, + "publisher_priority": null, + "subgroup_id": null, + "subgroup_id_mode": 1, + "track_alias": 9 + }, + { + "object": { + "object_id": 5, + "payload": "66697665", + "properties": [ + { + "type": 2, + "value": 40 + }, + { + "type": 62, + "value": 2 + } + ], + "status": 0 + } + }, + { + "object": { + "object_id": 6, + "payload": "", + "properties": [], + "status": 0 + } + }, + { + "object": { + "object_id": 8, + "payload": "57414931", + "properties": [ + { + "bytes": "ba400101431111111111111111111111111111111111111111111111111111111111111111000000222222222222222222222222222222222222222222222222222222222222222201030201", + "type": 11 + } + ], + "status": 0 + } + } + ], + "hex": "330907050402283c02046669766500000000014e0b4cba4001014311111111111111111111111111111111111111111111111111111111111111110000002222222222222222222222222222222222222222222222222222222222222222010302010457414931", + "kind": "uni-stream", + "name": "subgroup: Subgroup ID from the first object, default priority, properties" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "end_of_group": false, + "first_object": true, + "group_id": 300, + "header": "subgroup", + "properties": false, + "publisher_priority": 0, + "subgroup_id": 3, + "subgroup_id_mode": 2, + "track_alias": 1 + }, + { + "object": { + "object_id": 0, + "payload": "78", + "properties": [], + "status": 0 + } + }, + { + "object": { + "object_id": 1, + "payload": "", + "properties": [], + "status": 4 + } + } + ], + "hex": "5401812c0300000178000004", + "kind": "uni-stream", + "name": "subgroup: an explicit Subgroup ID, End of Track" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "end_of_group": false, + "first_object": true, + "group_id": 1, + "header": "subgroup", + "properties": false, + "publisher_priority": 9, + "subgroup_id": null, + "subgroup_id_mode": 0, + "track_alias": 4 + } + ], + "hex": "50040109", + "kind": "uni-stream", + "name": "subgroup: a header and no object" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "header": "fetch", + "request_id": 4 + }, + { + "fetch_object": { + "group_id": 1, + "object_id": 0, + "payload": "61", + "properties": null, + "publisher_priority": 7, + "subgroup_id": 0 + } + }, + { + "fetch_object": { + "group_id": 1, + "object_id": 1, + "payload": "62", + "properties": null, + "publisher_priority": 7, + "subgroup_id": 0 + } + }, + { + "fetch_object": { + "group_id": 1, + "object_id": 5, + "payload": "", + "properties": [ + { + "type": 62, + "value": 3 + } + ], + "publisher_priority": 9, + "subgroup_id": 1 + } + }, + { + "end_of_range": { + "kind": "non-existent", + "location": [ + 4, + 3 + ] + } + }, + { + "fetch_object": { + "group_id": 4, + "object_id": 4, + "payload": "64", + "properties": null, + "publisher_priority": 9, + "subgroup_id": null + } + }, + { + "end_of_range": { + "kind": "unknown", + "location": [ + 6, + 0 + ] + } + }, + { + "end_of_range": { + "kind": "timed-out", + "location": [ + 9, + 2 + ] + } + }, + { + "fetch_object": { + "group_id": 9, + "object_id": 3, + "payload": "65", + "properties": null, + "publisher_priority": 9, + "subgroup_id": 7 + } + }, + { + "fetch_object": { + "group_id": 9, + "object_id": 4, + "payload": "66", + "properties": null, + "publisher_priority": 2, + "subgroup_id": 8 + } + } + ], + "hex": "05041c0100070161000162360409023e0300808c0203400164810c0100820c02020307016512020166", + "kind": "uni-stream", + "name": "fetch in Ascending order, with each End of Range" + }, + { + "canonical": true, + "context": { + "group_order": 2 + }, + "expect": [ + { + "header": "fetch", + "request_id": 4 + }, + { + "fetch_object": { + "group_id": 9, + "object_id": 0, + "payload": "61", + "properties": null, + "publisher_priority": 7, + "subgroup_id": 0 + } + }, + { + "fetch_object": { + "group_id": 9, + "object_id": 1, + "payload": "62", + "properties": null, + "publisher_priority": 7, + "subgroup_id": 0 + } + }, + { + "fetch_object": { + "group_id": 9, + "object_id": 5, + "payload": "", + "properties": [ + { + "type": 62, + "value": 3 + } + ], + "publisher_priority": 9, + "subgroup_id": 1 + } + }, + { + "end_of_range": { + "kind": "non-existent", + "location": [ + 6, + 3 + ] + } + }, + { + "fetch_object": { + "group_id": 6, + "object_id": 4, + "payload": "64", + "properties": null, + "publisher_priority": 9, + "subgroup_id": null + } + }, + { + "end_of_range": { + "kind": "unknown", + "location": [ + 4, + 0 + ] + } + }, + { + "end_of_range": { + "kind": "timed-out", + "location": [ + 1, + 2 + ] + } + }, + { + "fetch_object": { + "group_id": 1, + "object_id": 3, + "payload": "65", + "properties": null, + "publisher_priority": 9, + "subgroup_id": 7 + } + }, + { + "fetch_object": { + "group_id": 1, + "object_id": 4, + "payload": "66", + "properties": null, + "publisher_priority": 2, + "subgroup_id": 8 + } + } + ], + "hex": "05041c0900070161000162360409023e0300808c0203400164810c0100820c02020307016512020166", + "kind": "uni-stream", + "name": "fetch in Descending order, with each End of Range" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "header": "fetch", + "request_id": 2 + }, + { + "end_of_range": { + "kind": "unknown", + "location": [ + 2, + 5 + ] + } + }, + { + "fetch_object": { + "group_id": 2, + "object_id": 6, + "payload": "7a", + "properties": null, + "publisher_priority": 1, + "subgroup_id": 0 + } + } + ], + "hex": "0502810c02051001017a", + "kind": "uni-stream", + "name": "fetch beginning with an End of Range" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "header": "fetch", + "request_id": 10 + } + ], + "hex": "050a", + "kind": "uni-stream", + "name": "fetch with no objects" + }, + { + "canonical": true, + "context": { + "group_order": 1 + }, + "expect": [ + { + "header": "padding" + }, + { + "padding": 8 + } + ], + "hex": "f0132b3e280000000000000000", + "kind": "uni-stream", + "name": "padding stream" + }, + { + "canonical": true, + "expect": { + "datagram": { + "end_of_group": false, + "group_id": 5, + "object_id": 0, + "payload": "6f707573", + "properties": null, + "publisher_priority": null, + "status": null, + "track_alias": 2 + } + }, + "hex": "0c02056f707573", + "kind": "datagram", + "name": "datagram: Object ID 0, default priority" + }, + { + "canonical": true, + "expect": { + "datagram": { + "end_of_group": true, + "group_id": 5, + "object_id": 9, + "payload": "6c617374", + "properties": [ + { + "type": 2, + "value": 20 + } + ], + "publisher_priority": 3, + "status": null, + "track_alias": 2 + } + }, + "hex": "03020509030202146c617374", + "kind": "datagram", + "name": "datagram: a priority, properties, End of Group" + }, + { + "canonical": true, + "expect": { + "datagram": { + "end_of_group": false, + "group_id": 6, + "object_id": 0, + "payload": null, + "properties": null, + "publisher_priority": 3, + "status": 4, + "track_alias": 2 + } + }, + "hex": "2402060304", + "kind": "datagram", + "name": "datagram: End of Track status" + }, + { + "canonical": true, + "expect": { + "datagram": { + "end_of_group": false, + "group_id": 1, + "object_id": 1, + "payload": null, + "properties": [ + { + "bytes": "70", + "type": 14917 + } + ], + "publisher_priority": null, + "status": 0, + "track_alias": 3 + } + }, + "hex": "2903010104ba45017000", + "kind": "datagram", + "name": "datagram: Normal status with properties" + }, + { + "canonical": true, + "expect": { + "datagram": { + "end_of_group": false, + "group_id": 1, + "object_id": 2, + "payload": "", + "properties": null, + "publisher_priority": null, + "status": null, + "track_alias": 3 + } + }, + "hex": "08030102", + "kind": "datagram", + "name": "datagram: an empty payload" + }, + { + "canonical": false, + "expect": { + "datagram": { + "end_of_group": false, + "group_id": 5, + "object_id": 0, + "payload": "78", + "properties": null, + "publisher_priority": null, + "status": null, + "track_alias": 2 + } + }, + "hex": "0802050078", + "kind": "datagram", + "name": "datagram: Object ID 0 written out" + }, + { + "canonical": true, + "expect": { + "padding": 4 + }, + "hex": "f0132b3e2900000000", + "kind": "datagram", + "name": "padding datagram" + }, + { + "canonical": true, + "expect": { + "acquisition": "", + "acquisition_evidence": "", + "content_hash": "1111111111111111111111111111111111111111111111111111111111111111", + "group_receipt": "2222222222222222222222222222222222222222222222222222222222222222", + "joules_micro": 0 + }, + "hex": "11111111111111111111111111111111111111111111111111111111111111110000002222222222222222222222222222222222222222222222222222222222222222", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: unmetered" + }, + { + "canonical": true, + "expect": { + "acquisition": "OnChipCounter", + "acquisition_evidence": "0000271000000000000f424001", + "content_hash": "3333333333333333333333333333333333333333333333333333333333333333", + "group_receipt": "4444444444444444444444444444444444444444444444444444444444444444", + "joules_micro": 1200000 + }, + "hex": "3333333333333333333333333333333333333333333333333333333333333333d24f800d4f6e43686970436f756e7465720d0000271000000000000f4240014444444444444444444444444444444444444444444444444444444444444444", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: OnChipCounter" + }, + { + "canonical": true, + "expect": { + "acquisition": "CalibratedInstrument", + "acquisition_evidence": "000007d0000000000003d0900000000b63616c2d323032362d3037", + "content_hash": "5555555555555555555555555555555555555555555555555555555555555555", + "group_receipt": "6666666666666666666666666666666666666666666666666666666666666666", + "joules_micro": 4000000 + }, + "hex": "5555555555555555555555555555555555555555555555555555555555555555e03d09001443616c69627261746564496e737472756d656e741b000007d0000000000003d0900000000b63616c2d323032362d30376666666666666666666666666666666666666666666666666666666666666666", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: CalibratedInstrument" + }, + { + "canonical": true, + "expect": { + "acquisition": "ModelBased", + "acquisition_evidence": "", + "content_hash": "7777777777777777777777777777777777777777777777777777777777777777", + "group_receipt": "8888888888888888888888888888888888888888888888888888888888888888", + "joules_micro": 300 + }, + "hex": "7777777777777777777777777777777777777777777777777777777777777777812c0a4d6f64656c4261736564008888888888888888888888888888888888888888888888888888888888888888", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: ModelBased" + }, + { + "canonical": false, + "expect": { + "consequence": "property_invalid", + "error": "invalid-property" + }, + "hex": "1111111111111111111111111111111111111111111111111111111111111111000000222222222222222222222222222222222222222222222222222222222222222200", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: a byte after the last field" + }, + { + "canonical": false, + "expect": { + "consequence": "property_invalid", + "error": "invalid-property" + }, + "hex": "111111111111111111111111111111111111111111111111111111111111111100000022222222222222222222222222222222222222222222222222", + "kind": "wai-receipt", + "name": "WAI_RECEIPT: cut short" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "0300090001016101620110", + "kind": "control", + "name": "control: the Length runs past the bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "0300080001016101620110", + "kind": "control", + "name": "control: a body shorter than its fields" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "trailing-bytes" + }, + "hex": "020003040000", + "kind": "control", + "name": "control: a byte after the last field" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "10000300ff01", + "kind": "control", + "name": "control: a varint that runs past its body" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-message-type" + }, + "hex": "400000", + "kind": "control", + "name": "control: type 0x40, reserved (CLIENT_SETUP up to -10)" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-message-type" + }, + "hex": "210000", + "kind": "control", + "name": "control: type 0x21, reserved (SERVER_SETUP up to -16)" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-message-type" + }, + "hex": "3f0000", + "kind": "control", + "name": "control: an unknown type" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "duplicate" + }, + "hex": "03000b0001016101620210010000", + "kind": "control", + "name": "SUBSCRIBE: FORWARD twice" + }, + { + "canonical": false, + "expect": { + "consequence": "request:INVALID_FILTER", + "error": "duplicate-filter" + }, + "hex": "03000f000101610162022602000100020004", + "kind": "control", + "name": "SUBSCRIBE: one OBJECTID_FILTER key twice" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-parameter" + }, + "hex": "030009000101610162010500", + "kind": "control", + "name": "SUBSCRIBE: an unknown parameter" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "parameter-not-allowed" + }, + "hex": "03000b0001016101620123021001", + "kind": "control", + "name": "SUBSCRIBE: FILL_PARAMETERS holding FORWARD" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-value" + }, + "hex": "030009000101610162011002", + "kind": "control", + "name": "SUBSCRIBE: FORWARD of 2" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-value" + }, + "hex": "030009000101610162012200", + "kind": "control", + "name": "SUBSCRIBE: GROUP_ORDER of 0" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-value" + }, + "hex": "030009000101610162012106", + "kind": "control", + "name": "SUBSCRIBE: a Location Filter Type of 6" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "overflow" + }, + "hex": "030014000101610162012103ffffffffffffffffff0001", + "kind": "control", + "name": "SUBSCRIBE: StartGroup + EndGroupDelta past 2^64-1" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "type-overflow" + }, + "hex": "030013000101610162023501ffffffffffffffffff01", + "kind": "control", + "name": "SUBSCRIBE: a parameter type past 2^64-1" + }, + { + "canonical": false, + "expect": { + "consequence": "request:INVALID_FILTER", + "error": "invalid-filter" + }, + "hex": "03000d00010161016201270400810000", + "kind": "control", + "name": "SUBSCRIBE: a PRIORITY_FILTER above 255" + }, + { + "canonical": false, + "expect": { + "consequence": "request:INVALID_FILTER", + "error": "invalid-filter" + }, + "hex": "03000c000101610162012803000b00", + "kind": "control", + "name": "SUBSCRIBE: an OBJECT_PROPERTY_FILTER on an odd type" + }, + { + "canonical": false, + "expect": { + "consequence": "request:INVALID_FILTER", + "error": "invalid-filter" + }, + "hex": "03001400010161016201260b0005ffffffffffffffffff", + "kind": "control", + "name": "SUBSCRIBE: a range end past 2^64-1" + }, + { + "canonical": false, + "expect": { + "consequence": "session:KEY_VALUE_FORMATTING_ERROR", + "error": "malformed-token" + }, + "hex": "03000b0001016101620103020400", + "kind": "control", + "name": "SUBSCRIBE: a token of an unknown alias type" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "parameter-not-allowed" + }, + "hex": "04000407011001", + "kind": "control", + "name": "SUBSCRIBE_OK: a parameter it may not carry (FORWARD)" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "too-many-namespace-fields" + }, + "hex": "500045002101780178017801780178017801780178017801780178017801780178017801780178017801780178017801780178017801780178017801780178017801780178017800", + "kind": "control", + "name": "SUBSCRIBE_NAMESPACE: 33 namespace fields" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "empty-namespace-field" + }, + "hex": "50000400010000", + "kind": "control", + "name": "SUBSCRIBE_NAMESPACE: an empty namespace field" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "length-exceeds-limit" + }, + "hex": "030fa600018fa06e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e6e80c8", + "kind": "control", + "name": "SUBSCRIBE: a full track name past 4,096 bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "length-exceeds-limit" + }, + "hex": "050405000084017272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272727272", + "kind": "control", + "name": "REQUEST_ERROR: a reason phrase of 1,025 bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "length-exceeds-limit" + }, + "hex": "100002a001", + "kind": "control", + "name": "GOAWAY: a URI of 8,193 bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "length-exceeds-limit" + }, + "hex": "0400070100ba45c10000", + "kind": "control", + "name": "SUBSCRIBE_OK: a property value of 65,536 bytes" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-value" + }, + "hex": "18000402000000", + "kind": "control", + "name": "FETCH_OK: End Of Track of 2" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "05000434000000", + "kind": "control", + "name": "REQUEST_ERROR: REDIRECT cut short" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "duplicate" + }, + "hex": "af00000406010002", + "kind": "control", + "name": "SETUP: MAX_FILTER_RANGES twice" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "type-overflow" + }, + "hex": "af00000cffffffffffffffffff000100", + "kind": "control", + "name": "SETUP: an option type past 2^64-1" + }, + { + "canonical": false, + "expect": { + "consequence": "session:KEY_VALUE_FORMATTING_ERROR", + "error": "malformed-token" + }, + "hex": "af000003030107", + "kind": "control", + "name": "SETUP: a token that does not read" + }, + { + "canonical": false, + "expect": { + "consequence": "malformed-track", + "error": "malformed-track" + }, + "hex": "04000701000b030b0100", + "kind": "control", + "name": "SUBSCRIBE_OK: Immutable Properties inside Immutable Properties" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-value" + }, + "hex": "04000401003002", + "kind": "control", + "name": "SUBSCRIBE_OK: DYNAMIC_GROUPS of 2" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unexpected-message" + }, + "hex": "af00001207106578616d706c652d696d706c2f312e301000030083e81000030083e8", + "kind": "uni-stream", + "name": "control stream: GOAWAY twice" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unexpected-message" + }, + "hex": "af00001207106578616d706c652d696d706c2f312e3003000700010161016200", + "kind": "uni-stream", + "name": "control stream: SUBSCRIBE after SETUP" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-stream-type" + }, + "hex": "0600", + "kind": "uni-stream", + "name": "stream: type 0x06" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-stream-type" + }, + "hex": "8091000000", + "kind": "uni-stream", + "name": "stream: a subgroup type of 0x91, two bytes" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-flags" + }, + "hex": "1600000000", + "kind": "uni-stream", + "name": "subgroup: SUBGROUP_ID_MODE 0b11" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-object-status" + }, + "hex": "10010207000001", + "kind": "uni-stream", + "name": "subgroup: Object Status 0x1" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "properties-on-non-normal" + }, + "hex": "11010207000202050003", + "kind": "uni-stream", + "name": "subgroup: properties on End of Group" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "1001020700056162", + "kind": "uni-stream", + "name": "subgroup: a FIN inside an object" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "overflow" + }, + "hex": "10010207ffffffffffffffffff0161000162", + "kind": "uni-stream", + "name": "subgroup: an Object ID past 2^64-1" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "malformed-track", + "error": "malformed-track" + }, + "hex": "1101020700050b030b01000178", + "kind": "uni-stream", + "name": "subgroup: Immutable Properties inside Immutable Properties" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "malformed-track", + "error": "malformed-track" + }, + "hex": "110102070004c04000010178", + "kind": "uni-stream", + "name": "subgroup: a Mandatory Track Property on an object" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "malformed-track", + "error": "malformed-track" + }, + "hex": "1101020701023e030178", + "kind": "uni-stream", + "name": "subgroup: a Prior Object ID Gap past the Object ID" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "no-prior-object" + }, + "hex": "05001d0000070161", + "kind": "uni-stream", + "name": "fetch: a first object that refers back" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-flags" + }, + "hex": "050080800000", + "kind": "uni-stream", + "name": "fetch: Serialization Flags of 0x80" + }, + { + "canonical": false, + "context": { + "group_order": 2 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "overflow" + }, + "hex": "05001c01000701610801000162", + "kind": "uni-stream", + "name": "fetch: a Group ID below 0 in Descending order" + }, + { + "canonical": false, + "context": { + "group_order": 1 + }, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "05001c0100070361", + "kind": "uni-stream", + "name": "fetch: a FIN inside an object" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-flags" + }, + "hex": "2201020300", + "kind": "datagram", + "name": "datagram: STATUS and END_OF_GROUP" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-flags" + }, + "hex": "1001020378", + "kind": "datagram", + "name": "datagram: bit 4 set" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "invalid-flags" + }, + "hex": "4001020378", + "kind": "datagram", + "name": "datagram: an undefined bit (0x40)" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "empty-datagram-properties" + }, + "hex": "0d01020078", + "kind": "datagram", + "name": "datagram: PROPERTIES with a length of 0" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "properties-on-non-normal" + }, + "hex": "2d010202020504", + "kind": "datagram", + "name": "datagram: properties beside End of Track" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "unknown-object-status" + }, + "hex": "2c010202", + "kind": "datagram", + "name": "datagram: an unknown status" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "trailing-bytes" + }, + "hex": "2c01020000", + "kind": "datagram", + "name": "datagram: a byte after the status" + }, + { + "canonical": false, + "expect": { + "consequence": "session:PROTOCOL_VIOLATION", + "error": "truncated" + }, + "hex": "0001", + "kind": "datagram", + "name": "datagram: cut short" + } + ] +} -- 2.54.0 (Apple Git-157) From 932ed158f4f1f3e37f27399b7a4e69d78584e692 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:14:39 -0400 Subject: [PATCH 4/7] joule-moq: an independent draft-22 decoder that checks the vector corpus tests/vectors/draft-22/moqt22_check.py, standard library only, written from the draft's sections: it reads every good vector as the corpus states and refuses every negative one with the stated kind and consequence, and fails on an empty corpus. Co-Authored-By: Claude Opus 5.5 --- .../tests/vectors/draft-22/moqt22_check.py | 763 ++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100755 joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py b/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py new file mode 100755 index 000000000..5af14e0bc --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py @@ -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)) -- 2.54.0 (Apple Git-157) From c9328f1a5c8c2ccb67105886fe5c4ae81c6f8114 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:17:12 -0400 Subject: [PATCH 5/7] joule-moq: generated draft-22 round trips, hostile input, and the vector check in CI tests/draft22_roundtrip.rs: a seeded generator builds valid control messages of every type, subgroup and fetch streams in both Group Orders, datagrams and WAI_RECEIPT values; encode, decode, encode is the identity. The encodings and the corpus, mutated, are fed to every decoder, whole and incrementally; none may panic. joule-code-conformance runs the independent decoder over the corpus. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/joule-code-conformance.yml | 19 + .../joule-moq/tests/draft22_roundtrip.rs | 502 ++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index 11aba64d2..0b76bc505 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -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: | diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs new file mode 100644 index 000000000..8b73b93c5 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs @@ -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 { + let n = self.below(max + 1); + (0..n).map(|_| self.next() as u8).collect() + } + fn nonempty(&mut self, max: u64) -> Vec { + 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 { + 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 { + let pool = [0x02u64, 0x06, 0x78, 0x79, 0x7e, 0x7f, 0x3800, 0x3a40, 0x3a41, 0x3a45, 0x3fff]; + let mut out: Vec = (0..r.below(4)) + .map(|_| { + let ty = pool[r.below(pool.len() as u64) as usize]; + if ty % 2 == 0 { + 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) { + 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 { + 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 { + 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> = 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); +} -- 2.54.0 (Apple Git-157) From 431dd9eacac79a84f69b409b8bdc26b7c2ed40d7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:20:04 -0400 Subject: [PATCH 6/7] joule-moq 0.3.0: README, changelog and version for draft-22 The crate README says which draft is implemented, the readings taken where -22 leaves an encoding open, the carriage code points and the WAI_RECEIPT layout, what is not implemented yet, and the vector corpus's JSON view. The version moves to 0.3.0 because the public API changed; both lockfiles that record it follow. Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 39 ++++ joule-code/examples/transport-moqt/Cargo.lock | 2 +- joule-code/joule-code-rs/Cargo.lock | 2 +- .../joule-code-rs/crates/joule-moq/Cargo.toml | 4 +- .../joule-code-rs/crates/joule-moq/README.md | 171 ++++++++++++++++++ 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq/README.md diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index b3c8af254..b6aaf84b4 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -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` diff --git a/joule-code/examples/transport-moqt/Cargo.lock b/joule-code/examples/transport-moqt/Cargo.lock index 56094914c..92127789c 100644 --- a/joule-code/examples/transport-moqt/Cargo.lock +++ b/joule-code/examples/transport-moqt/Cargo.lock @@ -654,7 +654,7 @@ dependencies = [ [[package]] name = "joule-moq" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "blake3", diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index 3b3c147cf..66ffbcc23 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "joule-moq" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "blake3", diff --git a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml index f2151ce62..43f2b47de 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml @@ -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" diff --git a/joule-code/joule-code-rs/crates/joule-moq/README.md b/joule-code/joule-code-rs/crates/joule-moq/README.md new file mode 100644 index 000000000..55db94679 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/README.md @@ -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:`, `request:` 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`). -- 2.54.0 (Apple Git-157) From b247c5a7014d71084a87b39889158a48a12519cf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 16 Oct 2026 10:22:34 -0400 Subject: [PATCH 7/7] joule-moq: clippy's style notes on the draft-22 code Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq/src/draft22/codes.rs | 2 +- .../crates/joule-moq/src/draft22/data.rs | 2 +- .../joule-code-rs/crates/joule-moq/src/draft22/io.rs | 12 ++++-------- .../crates/joule-moq/src/draft22/kvp.rs | 12 ++++-------- .../joule-code-rs/crates/joule-moq/src/mock.rs | 8 ++++---- .../crates/joule-moq/tests/draft22_roundtrip.rs | 2 +- .../crates/joule-moq/tests/draft22_vectors.rs | 2 +- 7 files changed, 16 insertions(+), 24 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs index 1260309d1..38d69af88 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs @@ -50,7 +50,7 @@ macro_rules! code_registry { /// Whether `v` follows §13's grease pattern `0x7f * N + 0x9D`. pub fn is_grease(v: u64) -> bool { - v >= 0x9d && (v - 0x9d) % 0x7f == 0 + v >= 0x9d && (v - 0x9d).is_multiple_of(0x7f) } code_registry! { diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs index 68218b80f..b8405fe3d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs @@ -1063,7 +1063,7 @@ mod tests { let b = encode_subgroup_stream(&hdr(true), &objs).unwrap(); let ev = decode_uni_stream(&b, GroupOrder::Ascending).unwrap(); assert_eq!(ev[0], UniEvent::Header(StreamHeader::Subgroup(hdr(true)))); - assert_eq!(ev[1..].iter().cloned().collect::>(), objs.iter().cloned().map(UniEvent::SubgroupObject).collect::>()); + assert_eq!(ev[1..].to_vec(), objs.iter().cloned().map(UniEvent::SubgroupObject).collect::>()); // Byte by byte, the same. let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default()); let mut got = Vec::new(); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs index 6b6a85caa..da78d9996 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs @@ -88,10 +88,8 @@ impl<'a> Reader<'a> { /// A varint length, at most `limit`, then that many bytes. pub fn length_prefixed(&mut self, limit: Option, what: &'static str) -> WireResult<&'a [u8]> { let n = self.varint(what)?; - if let Some(limit) = limit { - if n > limit { - return Err(WireError::LengthExceedsLimit { what, len: n, limit }); - } + if let Some(limit) = limit.filter(|l| n > *l) { + return Err(WireError::LengthExceedsLimit { what, len: n, limit }); } self.bytes(n, what) } @@ -113,10 +111,8 @@ pub fn put_varint(out: &mut Vec, v: u64) { /// Append a varint length, then the bytes; refused past `limit`. pub fn put_length_prefixed(out: &mut Vec, b: &[u8], limit: Option, what: &'static str) -> WireResult<()> { - if let Some(limit) = limit { - if b.len() as u64 > limit { - return Err(WireError::LengthExceedsLimit { what, len: b.len() as u64, limit }); - } + 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); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs index 5eb5fdc16..84a774722 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs @@ -201,10 +201,8 @@ impl Properties { if self.0.iter().filter(|p| p.ty == prop::IMMUTABLE_PROPERTIES).count() > 1 { return mt("more than one Immutable Properties"); } - if let Some(inner) = self.immutable()? { - if inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES) { - return mt("Immutable Properties inside 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)) { @@ -229,10 +227,8 @@ impl Properties { /// 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 let Some(inner) = self.immutable()? { - if inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES) { - return Err(WireError::MalformedTrack { reason: "Immutable Properties inside Immutable Properties" }); - } + 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(); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs index f190331ee..3aec100e7 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs @@ -70,10 +70,10 @@ impl MockFabric { /// 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) { - if 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())); - } + 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(()) diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs index 8b73b93c5..c28216330 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_roundtrip.rs @@ -180,7 +180,7 @@ fn safe_pairs(r: &mut Rng, nest: bool) -> Vec { let mut out: Vec = (0..r.below(4)) .map(|_| { let ty = pool[r.below(pool.len() as u64) as usize]; - if ty % 2 == 0 { + if ty.is_multiple_of(2) { KeyValuePair::varint(ty, r.wide()) } else { KeyValuePair::bytes(ty, r.bytes(16)) diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs index d4361a032..14575f5ea 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/draft22_vectors.rs @@ -373,7 +373,7 @@ fn build() -> Vec { let goaway = ControlMessage::Goaway(Goaway { new_session_uri: vec![], timeout_ms: 1000 }); c.stream( "control stream: SETUP then GOAWAY", - encode_control_stream(&setup, &[goaway.clone()]).unwrap(), + 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())], ); -- 2.54.0 (Apple Git-157)