joule-moq: draft-ietf-moq-transport-22 wire codec, with the crate moved onto it #283
35 changed files with 10833 additions and 1018 deletions
19
.github/workflows/joule-code-conformance.yml
vendored
19
.github/workflows/joule-code-conformance.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
2
joule-code/examples/transport-moqt/Cargo.lock
generated
2
joule-code/examples/transport-moqt/Cargo.lock
generated
|
|
@ -654,7 +654,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "joule-moq"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
|
|
|
|||
2
joule-code/joule-code-rs/Cargo.lock
generated
2
joule-code/joule-code-rs/Cargo.lock
generated
|
|
@ -1368,7 +1368,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "joule-moq"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
171
joule-code/joule-code-rs/crates/joule-moq/README.md
Normal file
171
joule-code/joule-code-rs/crates/joule-moq/README.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# joule-moq
|
||||
|
||||
MoqT (Media over QUIC Transport) for CommunicationOS broadcast media, with a JWP
|
||||
receipt extension: every object carries verifiable provenance and its energy
|
||||
figure.
|
||||
|
||||
## The draft it implements
|
||||
|
||||
`draft-ietf-moq-transport-22` (1 October 2026), and no other. The draft is chosen
|
||||
before the first MOQT byte, by the ALPN `moqt-22` on native QUIC or the
|
||||
`WT-Available-Protocols` value `moqt-22` on WebTransport (§6.2); `SETUP` carries no
|
||||
version. `MOQT_DRAFT_VERSION` is the conventional code `0xff000016`.
|
||||
|
||||
Drafts do not interoperate with each other. Deployed relays speak earlier drafts,
|
||||
which differ from -22 in their varint, their control streams and their messages, so
|
||||
this crate does not talk to them. The codec of -22 is a module of its own
|
||||
(`src/draft22/`), selected through `draft::Draft`: a later draft, or an earlier one
|
||||
added for interoperation, is a sibling module and a `Draft` variant, and the
|
||||
draft-neutral parts (tracks, objects, receipts, the session's state) stay as they are.
|
||||
|
||||
## What is implemented
|
||||
|
||||
The codec (`draft22`), every decoder bounded (it reads no byte that is not there and
|
||||
allocates nothing from a length before the bytes behind it exist) and every refusal a
|
||||
typed `WireError` carrying the draft's consequence: close the session with a code,
|
||||
reject the request, or a malformed track.
|
||||
|
||||
| -22 | module |
|
||||
|---|---|
|
||||
| §8.1 variable-length integer: 1 to 9 bytes, leading ones, longer forms accepted | `draft22::varint` |
|
||||
| §8.2 Location, §8.7 Track Namespace (at most 32 fields, none empty, 4,096 bytes for a Full Track Name), §8.8 rendering | `draft22::names` |
|
||||
| §8.3 Key-Value-Pairs (delta types, values up to 2^16-1 bytes), §8.4 and §10 Properties with Immutable Properties, Prior Group and Object ID Gaps and Mandatory Track Properties, §9.1 Setup Options | `draft22::kvp` |
|
||||
| §8.9 Authorization Token structure | `draft22::token` |
|
||||
| §9 every control message of Table 5, framed by a 16-bit Length | `draft22::control` |
|
||||
| §9.20 every Message Parameter, the list each message allows, the rules on repetition; §8.6 Range Filters; §9.20.9 Location Filters; FILL_PARAMETERS | `draft22::params` |
|
||||
| §6.3 and §6.4.2 control and request streams, Request IDs by end, §9.1.7 MAX_REQUEST_UPDATES credit, the order of messages on each request stream | `draft22::request` |
|
||||
| §11 subgroup streams, object datagrams, fetch streams with End of Range, padding; a whole-stream decoder and an incremental one | `draft22::data` |
|
||||
| §12 and §16.11 session, request, PUBLISH_DONE and stream reset codes; §13 grease | `draft22::codes` |
|
||||
|
||||
On it:
|
||||
|
||||
- `session`: the draft by ALPN, SETUP options, Request IDs, the receipt extension
|
||||
declared by a Setup Option.
|
||||
- `transport_quic` (feature `quic`): native QUIC on quinn. A pair of unidirectional
|
||||
control streams, each opened by SETUP; a bidirectional stream per request; subgroup,
|
||||
fetch and padding streams; object datagrams.
|
||||
- `mock`: an in-memory fabric and relay whose objects, receipts and requests cross
|
||||
the -22 codec.
|
||||
- `carriage`: how this crate's objects and receipts ride -22 objects (below).
|
||||
|
||||
### Where -22 leaves an encoding open
|
||||
|
||||
The codec takes one reading of each, and the vector checker takes the same:
|
||||
|
||||
- FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04) state no encoding; both are
|
||||
durations in milliseconds and are read as varints, as the draft encodes its other
|
||||
durations.
|
||||
- FILL_PARAMETERS is length-prefixed and holds "a sequence of Parameters": read up to
|
||||
its length, with no count before them, type deltas starting from 0.
|
||||
- End of Range (§11.4.1.2) says "the Group ID and Object ID fields are present"; the
|
||||
only such fields of a fetch object are the Group ID Delta and Object ID Delta,
|
||||
which bits 0x08 and 0x04 of 0x8C, 0x10C and 0x20C mark. They are read as an
|
||||
object's are: absolute on the first entry, and after it a Group ID that moves one
|
||||
or more groups in the request's Group Order, with an absolute Object ID.
|
||||
- An unknown message, stream or datagram type closes the session; -22 names no code,
|
||||
and this codec gives PROTOCOL_VIOLATION.
|
||||
|
||||
## Carriage of this crate's objects and receipts
|
||||
|
||||
Object Properties in the range -22 sets aside for an application's own use (§8.4),
|
||||
inside the object's Immutable Properties, since a relay must not change them:
|
||||
|
||||
| type | name | value |
|
||||
|---|---|---|
|
||||
| `0x3A40` | `MRL_LAYER` | varint: the object's layer, when the header states layers |
|
||||
| `0x3A41` | `WAI_RECEIPT` | bytes: WAI `extensions/moq-streaming-format.md` §5's property, laid out below |
|
||||
| `0x3A42` | `MRL_TOTAL_LAYERS` | varint, when non-zero |
|
||||
| `0x3A43` | `JWP_OBJECT_RECEIPT` | bytes: the object receipt, as this crate serialises it to JSON |
|
||||
| `0x3A44` | `WAI_ENVELOPE` | varint `1`, when the payload is a WAI envelope |
|
||||
|
||||
A layer cap on a SUBSCRIBE is an OBJECT_PROPERTY_FILTER on `MRL_LAYER`
|
||||
(`messages::mrl_cap_filter`), which needs the peer's MAX_FILTER_RANGES above 0. An
|
||||
object's delivery timeout rides as its OBJECT_DELIVERY_TIMEOUT property (§10.2).
|
||||
|
||||
The `WAI_RECEIPT` value: `content_hash` (32 bytes), `joules_micro` (a -22 varint),
|
||||
`acquisition` (a varint length, then UTF-8), `acquisition_evidence` (a varint length,
|
||||
then bytes), `group_receipt` (32 bytes), and nothing after.
|
||||
|
||||
A group's receipts ride its receipts track (moq-streaming-format §4.1): group *g*
|
||||
carries one object, a JSON array of the group receipt and the object receipts of
|
||||
group *g* of the media track.
|
||||
|
||||
The receipt extension is declared by the Setup Option `0x3A46`, a varint holding the
|
||||
extension's version. -22 has no unregistered range for Setup Options and asks for a
|
||||
provisional registration; until one is made the code point is this crate's choice,
|
||||
and a peer that does not know it ignores it (§9.1).
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
- WebTransport (MOQT over HTTP/3, §6.2.1), a relay with priority fan-out and
|
||||
per-object receipts, and a browser client: the next slice builds them on this codec.
|
||||
- The authorization token cache (§8.9: aliases, MAX_AUTH_TOKEN_CACHE_SIZE,
|
||||
retirement): the codec reads and writes tokens, and the session keeps no cache.
|
||||
- Evaluating filters at a publisher (§3.3), and holding a peer to its
|
||||
MAX_FILTER_RANGES: the codec reads and writes every filter and counts their ranges.
|
||||
- GOAWAY migration, delivery-timeout enforcement on real streams, and §5.1's
|
||||
scheduling in the QUIC transport; the mock relay applies delivery timeouts.
|
||||
- `draft-ietf-moq-loc` and `draft-ietf-moq-msf` semantics (catalogs, LOC
|
||||
properties): their properties ride as any property does.
|
||||
- The receipts-track payload in the JSON form of WAI `extensions/jwp-receipts.md`
|
||||
§2.4.1 and §2.4.2: today it is this crate's own JSON of the same receipts.
|
||||
|
||||
## The vector corpus
|
||||
|
||||
`tests/vectors/draft-22/vectors.json` holds -22 vectors: varints, every control
|
||||
message type, control, subgroup, fetch and padding streams, datagrams, and the
|
||||
`WAI_RECEIPT` value; and negative vectors, each with the refusal it must meet.
|
||||
`tests/draft22_vectors.rs` generates the file deterministically from typed values
|
||||
and holds the crate to it; `JOULE_MOQ_WRITE_VECTORS=1` rewrites it.
|
||||
`tests/vectors/draft-22/moqt22_check.py` decodes the corpus independently, from the
|
||||
draft's text, standard library only:
|
||||
|
||||
```sh
|
||||
python3 -I tests/vectors/draft-22/moqt22_check.py
|
||||
```
|
||||
|
||||
Each vector has a `name`, a `kind`, its bytes as `hex`, `canonical` (whether this
|
||||
crate's encoder makes exactly those bytes) and `expect`; a `uni-stream` vector also
|
||||
has `context.group_order` (1 Ascending, 2 Descending), the Group Order of the request
|
||||
a fetch stream answers.
|
||||
|
||||
| kind | bytes | reading |
|
||||
|---|---|---|
|
||||
| `varint` | one varint | `{"value", "length"}` |
|
||||
| `control` | one framed message | the message (below) |
|
||||
| `uni-stream` | a whole stream to its FIN | a list of events (below) |
|
||||
| `datagram` | one datagram | `{"datagram": {…}}` or `{"padding": n}` |
|
||||
| `wai-receipt` | the `WAI_RECEIPT` value | its five fields |
|
||||
|
||||
The JSON view: byte strings are lowercase hex; a Track Namespace is a list of its
|
||||
fields in hex; a Location is `[group, object]`; a Key-Value-Pair is `{"type", "value"}`
|
||||
for an even type and `{"type", "bytes"}` for an odd one, with Immutable Properties as
|
||||
raw bytes. A message is an object with `"message"` (its name in Table 5) and its
|
||||
fields by their names in the draft, lowercased with underscores. A parameter is
|
||||
`{"type": NAME}` with `"value"` (a varint or a uint8), `"token"`, `"location"`,
|
||||
`"filter"` (a Location Filter's `filter_type` and the fields it has; a Range Filter's
|
||||
`set_id`, `property_type` for the property filters, and `ranges` as `[start, end or
|
||||
null]`; `null` for Length 0), `"parameters"` (FILL_PARAMETERS) or `"namespace"`.
|
||||
Stream events are `{"header": "subgroup" | "fetch" | "control" | "padding", …}`,
|
||||
`{"control": message}`, `{"object": …}` (subgroup), `{"fetch_object": …}`,
|
||||
`{"end_of_range": {"kind", "location"}}` and `{"padding": n}`.
|
||||
|
||||
A refusal is `{"error": kind, "consequence": c}`, with `c` one of
|
||||
`session:<code>`, `request:<code>` or `malformed-track` (and `property_invalid` for
|
||||
the `WAI_RECEIPT` value, which a sink discards). The kinds are `WireError::kind`:
|
||||
`truncated`, `trailing-bytes`, `unknown-message-type`, `unknown-stream-type`,
|
||||
`invalid-flags`, `unknown-parameter`, `duplicate`, `parameter-not-allowed`,
|
||||
`duplicate-filter`, `invalid-filter`, `type-overflow`, `length-exceeds-limit`,
|
||||
`too-many-namespace-fields`, `empty-namespace-field`, `invalid-value`, `overflow`,
|
||||
`properties-on-non-normal`, `unknown-object-status`, `no-prior-object`,
|
||||
`empty-datagram-properties`, `malformed-token`, `unexpected-message`,
|
||||
`malformed-track`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `cargo test -p joule-moq --features quic --all-targets` runs the codec's unit
|
||||
tests, the corpus (`tests/draft22_vectors.rs`), generated round trips and hostile
|
||||
input (`tests/draft22_roundtrip.rs`), the QUIC transport over loopback, the mock
|
||||
and relay, the staged MoQ trace and WAI's JWP corpus.
|
||||
- The independent decoder above runs over the corpus in CI
|
||||
(`.github/workflows/joule-code-conformance.yml`).
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
//! Cross-machine MoQT-over-QUIC demo: a publisher streams signed,
|
||||
//! receipt-bearing objects; a subscriber on another host verifies every object
|
||||
//! against the publisher's pinned Ed25519 key over a real QUIC connection.
|
||||
//! Cross-machine MoQT-over-QUIC demo (`draft-ietf-moq-transport-22`, ALPN
|
||||
//! `moqt-22`): a publisher streams signed, receipt-bearing objects; a subscriber
|
||||
//! on another host verifies every object against the publisher's pinned Ed25519
|
||||
//! key over a real QUIC connection. The group's receipts ride its receipts track
|
||||
//! as one subgroup stream, the objects the media track as another, each object's
|
||||
//! receipt in its properties.
|
||||
//!
|
||||
//! Build: cargo build --example quic_xnet --features quic
|
||||
//! Publish (host A):
|
||||
|
|
@ -48,16 +51,16 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
use joule_moq::messages::ObjectHeader;
|
||||
use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier};
|
||||
use joule_moq::track::{GroupId, ObjectStatus, TrackAlias};
|
||||
use joule_moq::transport_quic::{
|
||||
generate_self_signed, read_group_receipt_from_uni, read_object_from_uni,
|
||||
write_group_receipt_to_uni, write_object_to_uni, QuicEndpoint,
|
||||
};
|
||||
use joule_moq::carriage::{ObjectExtras, ReceiptBundle};
|
||||
use joule_moq::draft22::{StreamHeader, SubgroupHeader, SubgroupId};
|
||||
use joule_moq::transport_quic::{generate_self_signed, send_object, send_receipt_bundle, QuicEndpoint};
|
||||
use rand_core_06::OsRng;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mode = args.get(1).map(|s| s.as_str()).unwrap_or("");
|
||||
let track_alias = TrackAlias(7);
|
||||
let receipts_alias = 8u64;
|
||||
let group_id = GroupId(1);
|
||||
let origin = "did:key:zJouleMoqPublisher";
|
||||
|
||||
|
|
@ -98,8 +101,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let group_end = chrono::Utc::now().to_rfc3339();
|
||||
let group_receipt = builder.finalize(&signing_key, "joule-moq-xnet", group_end);
|
||||
|
||||
let mut uni = session.open_uni().await?;
|
||||
write_group_receipt_to_uni(&mut uni, &group_receipt).await?;
|
||||
let mut sent = Vec::new();
|
||||
for ((id, payload, hash, joules), proof) in pending.into_iter().zip(proofs) {
|
||||
let header = ObjectHeader {
|
||||
track_alias,
|
||||
|
|
@ -122,9 +124,25 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
merkle_proof: proof,
|
||||
energy_class: None,
|
||||
};
|
||||
write_object_to_uni(&mut uni, &header, Some(&receipt), &payload).await?;
|
||||
sent.push((header, payload, receipt));
|
||||
}
|
||||
uni.finish()?;
|
||||
let bundle = ReceiptBundle { group: group_receipt.clone(), objects: sent.iter().map(|s| s.2.clone()).collect() };
|
||||
send_receipt_bundle(&session, receipts_alias, group_id.0, 128, &bundle).await?;
|
||||
let mut media = session
|
||||
.open_subgroup(SubgroupHeader {
|
||||
track_alias: track_alias.0,
|
||||
group_id: group_id.0,
|
||||
subgroup_id: SubgroupId::Zero,
|
||||
publisher_priority: Some(128),
|
||||
properties: true,
|
||||
end_of_group: true,
|
||||
first_object: true,
|
||||
})
|
||||
.await?;
|
||||
for (header, payload, receipt) in sent {
|
||||
send_object(&mut media, &header, &payload, &ObjectExtras { receipt: Some(receipt), wai_receipt: None, delivery_timeout_ms: None }).await?;
|
||||
}
|
||||
media.finish()?;
|
||||
println!("publisher: streamed group receipt + {} objects", group_receipt.leaf_count);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
println!("publisher: done");
|
||||
|
|
@ -145,8 +163,20 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
let session = client.connect(connect, "joule-pub").await?;
|
||||
println!("subscriber: connected to {connect}");
|
||||
|
||||
let mut uni = session.accept_uni().await?;
|
||||
let group_receipt = read_group_receipt_from_uni(&mut uni).await?;
|
||||
let mut receipts = None;
|
||||
let mut objects = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut rx = session.accept_data().await?;
|
||||
let StreamHeader::Subgroup(h) = rx.header else { return Err("expected a subgroup stream".into()) };
|
||||
if h.track_alias == receipts_alias {
|
||||
receipts = Some(rx.read_receipt_bundle().await?);
|
||||
} else {
|
||||
while let Some(o) = rx.next_object().await? {
|
||||
objects.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
let group_receipt = receipts.ok_or("no receipts track")?.group;
|
||||
|
||||
let verifier = ReceiptVerifier::with_anchors(vec![publisher_key]);
|
||||
let root = verifier.verify_group(&group_receipt)?;
|
||||
|
|
@ -157,9 +187,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
hex_encode(&root.root[..8]),
|
||||
);
|
||||
|
||||
for i in 0..group_receipt.leaf_count {
|
||||
let (_header, receipt, payload) = read_object_from_uni(&mut uni).await?;
|
||||
let receipt = receipt.ok_or("object missing its receipt")?;
|
||||
for (i, (_header, payload, extras)) in objects.into_iter().enumerate() {
|
||||
let receipt = extras.receipt.ok_or("object missing its receipt")?;
|
||||
verifier.verify_payload(&payload, &receipt, &root)?;
|
||||
println!(
|
||||
"subscriber: object {i} VERIFIED — {} ({} µJ)",
|
||||
|
|
|
|||
477
joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs
Normal file
477
joule-code/joule-code-rs/crates/joule-moq/src/carriage.rs
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
//! How this crate's objects and receipts ride MOQT -22.
|
||||
//!
|
||||
//! The crate's data model is draft-neutral: an [`ObjectHeader`], a payload, an
|
||||
//! [`ObjectReceipt`]. This module maps it onto -22 objects:
|
||||
//!
|
||||
//! - **Object Properties** in the range -22 sets aside for an application's own
|
||||
//! use without registration (`0x3800`-`0x3FFF`, §8.4), all inside the
|
||||
//! object's Immutable Properties (§10.7), since a relay must not change them:
|
||||
//!
|
||||
//! | type | name | value |
|
||||
//! |---|---|---|
|
||||
//! | `0x3A40` | `MRL_LAYER` | varint: [`ObjectHeader::mrl_layer`], when the header states layers |
|
||||
//! | `0x3A41` | `WAI_RECEIPT` | bytes: the property of WAI `extensions/moq-streaming-format.md` §5, laid out as [`WaiReceiptProperty::to_bytes`] says |
|
||||
//! | `0x3A42` | `MRL_TOTAL_LAYERS` | varint: [`ObjectHeader::mrl_total_layers`], when non-zero |
|
||||
//! | `0x3A43` | `JWP_OBJECT_RECEIPT` | bytes: the object's receipt as JSON (this crate's serialisation of [`ObjectReceipt`]); WAI `extensions/jwp-receipts.md` §1 lets a receipt ride "a MoQT object header field" |
|
||||
//! | `0x3A44` | `WAI_ENVELOPE` | varint `1`: [`ObjectHeader::wai_envelope`], when set |
|
||||
//!
|
||||
//! An even type is a varint and an odd one bytes, as §8.3 requires. Another
|
||||
//! application may give these code points other meanings (§8.4); a sink
|
||||
//! reads them only on tracks it knows carry this crate's objects.
|
||||
//! - **A receipts track** (moq-streaming-format §4.1): group *g* of it carries
|
||||
//! one object whose payload is a JSON array of the group receipt and the
|
||||
//! object receipts of group *g* of its media track ([`ReceiptBundle`]).
|
||||
//! - **A Setup Option**, [`SETUP_OPTION_JWP_RECEIPTS`], by which an endpoint
|
||||
//! declares the JWP receipt extension and its version (§6.3.2). -22 has no
|
||||
//! unregistered range for Setup Options and asks for a provisional
|
||||
//! registration; until one is made this code point is this crate's choice,
|
||||
//! and a peer that does not know it ignores it (§9.1).
|
||||
|
||||
use bytes::Bytes;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::draft22::data::{ObjectStatus as WireStatus, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject};
|
||||
use crate::draft22::io::{put_varint, Reader};
|
||||
use crate::draft22::kvp::{prop as wire_prop, KeyValuePair, Properties};
|
||||
use crate::draft22::{decode_uni_stream, GroupOrder, StreamHeader, UniEvent, WireError};
|
||||
use crate::error::{MoqError, MoqResult};
|
||||
use crate::messages::ObjectHeader;
|
||||
use crate::receipt::{check_class, GroupReceipt, ObjectReceipt};
|
||||
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias};
|
||||
|
||||
/// The object property types this crate defines (see the module documentation).
|
||||
pub mod prop {
|
||||
pub const MRL_LAYER: u64 = 0x3a40;
|
||||
pub const WAI_RECEIPT: u64 = 0x3a41;
|
||||
pub const MRL_TOTAL_LAYERS: u64 = 0x3a42;
|
||||
pub const JWP_OBJECT_RECEIPT: u64 = 0x3a43;
|
||||
pub const WAI_ENVELOPE: u64 = 0x3a44;
|
||||
}
|
||||
|
||||
/// The Setup Option that declares the JWP receipt extension; its value (a
|
||||
/// varint, the type being even) is [`crate::JWP_MOQ_EXTENSION_VERSION`].
|
||||
pub const SETUP_OPTION_JWP_RECEIPTS: u64 = 0x3a46;
|
||||
|
||||
/// The priority an object takes when its subgroup gives none and the track no
|
||||
/// DEFAULT_PUBLISHER_PRIORITY (§10.4: 128).
|
||||
pub const DEFAULT_PRIORITY: u8 = 128;
|
||||
|
||||
// ── WAI_RECEIPT ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The `WAI_RECEIPT` property of WAI moq-streaming-format §5: the object's
|
||||
/// receipt commitment, unsigned.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WaiReceiptProperty {
|
||||
/// jwp-receipts §2.1 BLAKE3 over the envelope bytes.
|
||||
pub content_hash: [u8; 32],
|
||||
/// Microjoules producing the object; 0 is the unmetered marker.
|
||||
pub joules_micro: u64,
|
||||
/// The acquisition class's label; empty beside 0.
|
||||
pub acquisition: String,
|
||||
/// For `OnChipCounter` and `CalibratedInstrument`, the evidence after the
|
||||
/// class's tag byte; empty for every other class.
|
||||
pub acquisition_evidence: Vec<u8>,
|
||||
/// The link hash of the group receipt covering the object.
|
||||
pub group_receipt: [u8; 32],
|
||||
}
|
||||
|
||||
/// The class labels a property may name, with their tags in the signed
|
||||
/// encoding of WAI energy-measurement §2.7.
|
||||
const CLASS_LABELS: [(&str, u8); 5] = [("HwShunt", 1), ("ModelBased", 2), ("Estimator", 3), ("OnChipCounter", 4), ("CalibratedInstrument", 5)];
|
||||
|
||||
impl WaiReceiptProperty {
|
||||
/// The property's value bytes: `content_hash` (32 bytes), `joules_micro` (a
|
||||
/// -22 varint), `acquisition` (a varint length, then UTF-8),
|
||||
/// `acquisition_evidence` (a varint length, then bytes), `group_receipt` (32
|
||||
/// bytes). Nothing follows.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(80 + self.acquisition.len() + self.acquisition_evidence.len());
|
||||
out.extend_from_slice(&self.content_hash);
|
||||
put_varint(&mut out, self.joules_micro);
|
||||
put_varint(&mut out, self.acquisition.len() as u64);
|
||||
out.extend_from_slice(self.acquisition.as_bytes());
|
||||
put_varint(&mut out, self.acquisition_evidence.len() as u64);
|
||||
out.extend_from_slice(&self.acquisition_evidence);
|
||||
out.extend_from_slice(&self.group_receipt);
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the value bytes; refused where a field is cut short, the label is not
|
||||
/// UTF-8, or bytes follow the last field.
|
||||
pub fn from_bytes(b: &[u8]) -> MoqResult<Self> {
|
||||
let bad = |e: WireError| MoqError::ReceiptVerification(format!("WAI_RECEIPT property: {e}"));
|
||||
let mut r = Reader::new(b);
|
||||
let content_hash = r.array32("content_hash").map_err(bad)?;
|
||||
let joules_micro = r.varint("joules_micro").map_err(bad)?;
|
||||
let acquisition = std::str::from_utf8(r.length_prefixed(None, "acquisition").map_err(bad)?)
|
||||
.map_err(|_| MoqError::ReceiptVerification("WAI_RECEIPT property: acquisition is not UTF-8".into()))?
|
||||
.to_owned();
|
||||
let acquisition_evidence = r.length_prefixed(None, "acquisition_evidence").map_err(bad)?.to_vec();
|
||||
let group_receipt = r.array32("group_receipt").map_err(bad)?;
|
||||
r.finish("WAI_RECEIPT property").map_err(bad)?;
|
||||
Ok(WaiReceiptProperty { content_hash, joules_micro, acquisition, acquisition_evidence, group_receipt })
|
||||
}
|
||||
|
||||
/// moq-streaming-format §5's rules on the property alone
|
||||
/// (`property_invalid`): a zero figure with no class and no evidence; a
|
||||
/// non-zero figure with a class this crate can read and, for a refined
|
||||
/// hardware class, its evidence at the resolution floor; legacy `HwShunt`
|
||||
/// with no evidence.
|
||||
pub fn check(&self) -> MoqResult<()> {
|
||||
let bad = |m: &str| Err(MoqError::ReceiptVerification(format!("WAI_RECEIPT property: {m}")));
|
||||
if self.joules_micro == 0 {
|
||||
return if self.acquisition.is_empty() && self.acquisition_evidence.is_empty() { Ok(()) } else { bad("a class beside the unmetered marker") };
|
||||
}
|
||||
let Some(&(_, tag)) = CLASS_LABELS.iter().find(|(l, _)| *l == self.acquisition) else {
|
||||
return bad("a figure without a class this crate reads");
|
||||
};
|
||||
if tag == 1 {
|
||||
return if self.acquisition_evidence.is_empty() { Ok(()) } else { bad("evidence beside HwShunt") };
|
||||
}
|
||||
let mut class = vec![tag];
|
||||
class.extend_from_slice(&self.acquisition_evidence);
|
||||
check_class(&class, self.joules_micro).or_else(|m| bad(&m))
|
||||
}
|
||||
|
||||
/// The property a publisher sets for `object` under `group`: the object's
|
||||
/// figure and, from its labelled leaf, its class. `None` where the receipt
|
||||
/// gives a non-zero figure and no class, which no valid property can state.
|
||||
pub fn from_receipts(object: &ObjectReceipt, group: &GroupReceipt) -> Option<Self> {
|
||||
let (acquisition, acquisition_evidence) = match (&object.energy_class, object.joules_micro) {
|
||||
(_, 0) => (String::new(), Vec::new()),
|
||||
(Some(c), _) => {
|
||||
let (&tag, ev) = c.split_first()?;
|
||||
let label = CLASS_LABELS.iter().find(|(_, t)| *t == tag)?.0;
|
||||
(label.to_owned(), ev.to_vec())
|
||||
}
|
||||
(None, _) => return None,
|
||||
};
|
||||
let p = WaiReceiptProperty { content_hash: object.content_hash, joules_micro: object.joules_micro, acquisition, acquisition_evidence, group_receipt: group.link_hash() };
|
||||
p.check().ok()?;
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Whether the property names this object and its group, and, where the
|
||||
/// object's leaf is labelled, states the leaf's figure and class
|
||||
/// (moq-streaming-format §5: otherwise `property_disagrees`).
|
||||
pub fn agrees_with(&self, object: &ObjectReceipt, group: &GroupReceipt) -> bool {
|
||||
if self.content_hash != object.content_hash || self.group_receipt != group.link_hash() {
|
||||
return false;
|
||||
}
|
||||
match &object.energy_class {
|
||||
None => true,
|
||||
Some(c) => {
|
||||
let mine = match CLASS_LABELS.iter().find(|(l, _)| *l == self.acquisition) {
|
||||
Some(&(_, tag)) => {
|
||||
let mut v = vec![tag];
|
||||
v.extend_from_slice(&self.acquisition_evidence);
|
||||
v
|
||||
}
|
||||
None if self.acquisition.is_empty() => vec![0],
|
||||
None => return false,
|
||||
};
|
||||
self.joules_micro == object.joules_micro && &mine == c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Objects ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn status_to_wire(s: ObjectStatus) -> MoqResult<WireStatus> {
|
||||
match s {
|
||||
ObjectStatus::Normal => Ok(WireStatus::Normal),
|
||||
ObjectStatus::EndOfGroup => Ok(WireStatus::EndOfGroup),
|
||||
ObjectStatus::EndOfTrack => Ok(WireStatus::EndOfTrack),
|
||||
ObjectStatus::DoesNotExist | ObjectStatus::GroupDoesNotExist => Err(MoqError::WireEncode(
|
||||
"-22 has no Object Status for an object or group that does not exist; it states that by a Prior Object or Group ID Gap property or a fetch End of Range".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_from_wire(s: WireStatus) -> ObjectStatus {
|
||||
match s {
|
||||
WireStatus::Normal => ObjectStatus::Normal,
|
||||
WireStatus::EndOfGroup => ObjectStatus::EndOfGroup,
|
||||
WireStatus::EndOfTrack => ObjectStatus::EndOfTrack,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything an object carries besides its payload, as this crate reads it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ObjectExtras {
|
||||
pub receipt: Option<ObjectReceipt>,
|
||||
pub wai_receipt: Option<WaiReceiptProperty>,
|
||||
/// OBJECT_DELIVERY_TIMEOUT set on the object (§5.2, §10.2).
|
||||
pub delivery_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// The object's properties: this crate's, inside Immutable Properties, and
|
||||
/// OBJECT_DELIVERY_TIMEOUT where the object sets one (§10.2: read on a
|
||||
/// subgroup's first object).
|
||||
pub fn object_properties(h: &ObjectHeader, extras: &ObjectExtras) -> MoqResult<Properties> {
|
||||
let mut inner = Properties::new();
|
||||
if h.mrl_total_layers != 0 {
|
||||
inner.push(KeyValuePair::varint(prop::MRL_LAYER, h.mrl_layer as u64));
|
||||
inner.push(KeyValuePair::varint(prop::MRL_TOTAL_LAYERS, h.mrl_total_layers as u64));
|
||||
}
|
||||
if h.wai_envelope {
|
||||
inner.push(KeyValuePair::varint(prop::WAI_ENVELOPE, 1));
|
||||
}
|
||||
if let Some(w) = &extras.wai_receipt {
|
||||
inner.push(KeyValuePair::bytes(prop::WAI_RECEIPT, w.to_bytes()));
|
||||
}
|
||||
if let Some(r) = &extras.receipt {
|
||||
let json = serde_json::to_vec(r).map_err(|e| MoqError::WireEncode(format!("object receipt: {e}")))?;
|
||||
inner.push(KeyValuePair::bytes(prop::JWP_OBJECT_RECEIPT, json));
|
||||
}
|
||||
let mut p = Properties::new();
|
||||
if let Some(t) = extras.delivery_timeout_ms {
|
||||
p.push(KeyValuePair::varint(wire_prop::OBJECT_DELIVERY_TIMEOUT, t));
|
||||
}
|
||||
if !inner.is_empty() {
|
||||
p.set_immutable(&inner)?;
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Read this crate's properties back: the MRL fields and the envelope flag into
|
||||
/// `h`, and the receipt, the WAI_RECEIPT property and the delivery timeout.
|
||||
pub fn read_object_properties(p: &Properties, h: &mut ObjectHeader) -> MoqResult<ObjectExtras> {
|
||||
let small = |v: Option<u64>, what: &str| -> MoqResult<u8> {
|
||||
let v = v.unwrap_or(0);
|
||||
u8::try_from(v).map_err(|_| MoqError::WireDecode(format!("{what} {v} is above 255")))
|
||||
};
|
||||
let one = |ty: u64| -> MoqResult<Option<KeyValuePair>> { Ok(p.find(ty)?) };
|
||||
h.mrl_layer = small(one(prop::MRL_LAYER)?.and_then(|k| k.as_varint()), "MRL layer")?;
|
||||
h.mrl_total_layers = small(one(prop::MRL_TOTAL_LAYERS)?.and_then(|k| k.as_varint()), "MRL total layers")?;
|
||||
h.wai_envelope = one(prop::WAI_ENVELOPE)?.and_then(|k| k.as_varint()) == Some(1);
|
||||
let wai_receipt = match one(prop::WAI_RECEIPT)?.as_ref().and_then(KeyValuePair::as_bytes) {
|
||||
Some(b) => Some(WaiReceiptProperty::from_bytes(b)?),
|
||||
None => None,
|
||||
};
|
||||
let receipt = match one(prop::JWP_OBJECT_RECEIPT)?.as_ref().and_then(KeyValuePair::as_bytes) {
|
||||
Some(b) => Some(serde_json::from_slice(b).map_err(|e| MoqError::WireDecode(format!("object receipt: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let delivery_timeout_ms = one(wire_prop::OBJECT_DELIVERY_TIMEOUT)?.and_then(|k| k.as_varint());
|
||||
Ok(ObjectExtras { receipt, wai_receipt, delivery_timeout_ms })
|
||||
}
|
||||
|
||||
/// The subgroup header an object of this crate rides in: its track alias and
|
||||
/// group, Subgroup ID 0, its priority, properties on.
|
||||
pub fn subgroup_header_for(h: &ObjectHeader) -> SubgroupHeader {
|
||||
SubgroupHeader {
|
||||
track_alias: h.track_alias.0,
|
||||
group_id: h.group_id.0,
|
||||
subgroup_id: SubgroupId::Zero,
|
||||
publisher_priority: Some(h.publisher_priority),
|
||||
properties: true,
|
||||
end_of_group: false,
|
||||
first_object: h.object_id.0 == 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The -22 object for a header, payload and extras.
|
||||
pub fn to_subgroup_object(h: &ObjectHeader, payload: &[u8], extras: &ObjectExtras) -> MoqResult<SubgroupObject> {
|
||||
let status = status_to_wire(h.status)?;
|
||||
let properties = if status == WireStatus::Normal { object_properties(h, extras)? } else { Properties::new() };
|
||||
Ok(SubgroupObject { object_id: h.object_id.0, properties, status, payload: payload.to_vec() })
|
||||
}
|
||||
|
||||
/// The header, payload and extras of a -22 subgroup object.
|
||||
pub fn from_subgroup_object(sh: &SubgroupHeader, o: &SubgroupObject) -> MoqResult<(ObjectHeader, Bytes, ObjectExtras)> {
|
||||
let mut h = ObjectHeader {
|
||||
track_alias: TrackAlias(sh.track_alias),
|
||||
group_id: GroupId(sh.group_id),
|
||||
object_id: ObjectId(o.object_id),
|
||||
publisher_priority: sh.publisher_priority.unwrap_or(DEFAULT_PRIORITY),
|
||||
status: status_from_wire(o.status),
|
||||
payload_len: o.payload.len() as u64,
|
||||
mrl_layer: 0,
|
||||
mrl_total_layers: 0,
|
||||
wai_envelope: false,
|
||||
};
|
||||
let extras = read_object_properties(&o.properties, &mut h)?;
|
||||
Ok((h, Bytes::from(o.payload.clone()), extras))
|
||||
}
|
||||
|
||||
/// One object as a whole subgroup stream of its own: header, then the object.
|
||||
pub fn encode_object_stream(h: &ObjectHeader, payload: &[u8], extras: &ObjectExtras) -> MoqResult<Vec<u8>> {
|
||||
let sh = subgroup_header_for(h);
|
||||
let mut out = Vec::new();
|
||||
sh.encode(&mut out);
|
||||
SubgroupCodec::new(sh).encode_object(&to_subgroup_object(h, payload, extras)?, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a subgroup stream that holds exactly one object.
|
||||
pub fn decode_object_stream(b: &[u8]) -> MoqResult<(ObjectHeader, Bytes, ObjectExtras)> {
|
||||
let ev = decode_uni_stream(b, GroupOrder::Ascending)?;
|
||||
match ev.as_slice() {
|
||||
[UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] => from_subgroup_object(sh, o),
|
||||
_ => Err(MoqError::WireDecode("not a subgroup stream of one object".into())),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipts tracks ─────────────────────────────────────────────────────────
|
||||
|
||||
/// One group's receipts, as one object of a receipts track carries them
|
||||
/// (moq-streaming-format §4.1): the group receipt and the object receipts of one
|
||||
/// group of the receipts track's media track.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ReceiptBundle {
|
||||
pub group: GroupReceipt,
|
||||
pub objects: Vec<ObjectReceipt>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BundleItem {
|
||||
Group(GroupReceipt),
|
||||
Object(ObjectReceipt),
|
||||
}
|
||||
|
||||
impl ReceiptBundle {
|
||||
/// The object's payload: a JSON array, the group receipt first, then the
|
||||
/// object receipts, in this crate's serialisation of each.
|
||||
pub fn to_payload(&self) -> MoqResult<Vec<u8>> {
|
||||
let mut items: Vec<BundleItem> = vec![BundleItem::Group(self.group.clone())];
|
||||
items.extend(self.objects.iter().cloned().map(BundleItem::Object));
|
||||
serde_json::to_vec(&items).map_err(|e| MoqError::WireEncode(format!("receipt bundle: {e}")))
|
||||
}
|
||||
|
||||
pub fn from_payload(b: &[u8]) -> MoqResult<Self> {
|
||||
let v: Vec<serde_json::Value> = serde_json::from_slice(b).map_err(|e| MoqError::WireDecode(format!("receipt bundle: {e}")))?;
|
||||
let mut it = v.into_iter();
|
||||
let group: GroupReceipt = serde_json::from_value(it.next().ok_or_else(|| MoqError::WireDecode("receipt bundle: empty".into()))?)
|
||||
.map_err(|e| MoqError::WireDecode(format!("receipt bundle group: {e}")))?;
|
||||
let objects = it
|
||||
.map(|o| serde_json::from_value(o).map_err(|e| MoqError::WireDecode(format!("receipt bundle object: {e}"))))
|
||||
.collect::<MoqResult<Vec<ObjectReceipt>>>()?;
|
||||
Ok(ReceiptBundle { group, objects })
|
||||
}
|
||||
|
||||
/// The bundle as group `group` of a receipts track whose alias is
|
||||
/// `receipts_alias`: a subgroup stream of one object, Object ID 0, at its
|
||||
/// media track's priority (§4.1).
|
||||
pub fn encode_stream(&self, receipts_alias: u64, group: u64, priority: u8) -> MoqResult<Vec<u8>> {
|
||||
let sh = SubgroupHeader {
|
||||
track_alias: receipts_alias,
|
||||
group_id: group,
|
||||
subgroup_id: SubgroupId::Zero,
|
||||
publisher_priority: Some(priority),
|
||||
properties: false,
|
||||
end_of_group: true,
|
||||
first_object: true,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
sh.encode(&mut out);
|
||||
SubgroupCodec::new(sh).encode_object(&SubgroupObject::normal(0, self.to_payload()?), &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a receipts-track stream: its header and the bundle of its one object.
|
||||
pub fn decode_stream(b: &[u8]) -> MoqResult<(SubgroupHeader, Self)> {
|
||||
let ev = decode_uni_stream(b, GroupOrder::Ascending)?;
|
||||
match ev.as_slice() {
|
||||
[UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] => Ok((*sh, Self::from_payload(&o.payload)?)),
|
||||
_ => Err(MoqError::WireDecode("not a receipts-track stream of one object".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::receipt::ReceiptBuilder;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn signed(class: Option<Vec<u8>>, joules: u64) -> (ObjectReceipt, GroupReceipt) {
|
||||
let key = SigningKey::from_bytes(&[7; 32]);
|
||||
let hash = ObjectReceipt::hash_payload(b"envelope");
|
||||
let mut b = ReceiptBuilder::new(TrackAlias(3), GroupId(9));
|
||||
match &class {
|
||||
Some(c) => {
|
||||
b.add_labelled_object(hash, joules, "o", "t", c.clone()).unwrap();
|
||||
}
|
||||
None => {
|
||||
b.add_object(hash, joules, "o", "t");
|
||||
}
|
||||
}
|
||||
let proof = b.proof_for(0).unwrap();
|
||||
let group = match &class {
|
||||
Some(_) => b.finalize_labelled(&key, "p", "t", None).unwrap(),
|
||||
None => b.finalize(&key, "p", "t"),
|
||||
};
|
||||
let o = ObjectReceipt { track_alias: TrackAlias(3), group_id: GroupId(9), object_id: ObjectId(0), content_hash: hash, joules_micro: joules, origin: "o".into(), merkle_proof: proof, energy_class: class };
|
||||
(o, group)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_property_from_receipts_and_back() {
|
||||
// Unmetered.
|
||||
let (o, g) = signed(Some(vec![0]), 0);
|
||||
let p = WaiReceiptProperty::from_receipts(&o, &g).unwrap();
|
||||
assert_eq!((p.joules_micro, p.acquisition.as_str()), (0, ""));
|
||||
assert_eq!(WaiReceiptProperty::from_bytes(&p.to_bytes()).unwrap(), p);
|
||||
assert!(p.agrees_with(&o, &g));
|
||||
// A refined class: OnChipCounter, 1% over 1 s, filtering off.
|
||||
let mut c = vec![4];
|
||||
c.extend_from_slice(&10_000u32.to_be_bytes());
|
||||
c.extend_from_slice(&1_000_000u64.to_be_bytes());
|
||||
c.push(1);
|
||||
let (o, g) = signed(Some(c), 1_200_000);
|
||||
let p = WaiReceiptProperty::from_receipts(&o, &g).unwrap();
|
||||
assert_eq!(p.acquisition, "OnChipCounter");
|
||||
assert_eq!(p.acquisition_evidence.len(), 13);
|
||||
assert!(p.agrees_with(&o, &g));
|
||||
let mut other = p.clone();
|
||||
other.joules_micro += 1;
|
||||
assert!(!other.agrees_with(&o, &g));
|
||||
// A legacy figure with no class has no valid property.
|
||||
let (o, g) = signed(None, 100);
|
||||
assert_eq!(WaiReceiptProperty::from_receipts(&o, &g), None);
|
||||
// Trailing bytes and a class beside zero are refused.
|
||||
let mut b = p.to_bytes();
|
||||
b.push(0);
|
||||
assert!(WaiReceiptProperty::from_bytes(&b).is_err());
|
||||
let z = WaiReceiptProperty { joules_micro: 0, ..p };
|
||||
assert!(z.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_rides_a_subgroup_stream() {
|
||||
let (o, g) = signed(Some(vec![0]), 0);
|
||||
let h = ObjectHeader {
|
||||
track_alias: TrackAlias(3),
|
||||
group_id: GroupId(9),
|
||||
object_id: ObjectId(0),
|
||||
publisher_priority: 40,
|
||||
status: ObjectStatus::Normal,
|
||||
payload_len: 8,
|
||||
mrl_layer: 1,
|
||||
mrl_total_layers: 3,
|
||||
wai_envelope: true,
|
||||
};
|
||||
let extras = ObjectExtras { wai_receipt: WaiReceiptProperty::from_receipts(&o, &g), receipt: Some(o), delivery_timeout_ms: Some(50) };
|
||||
let b = encode_object_stream(&h, b"envelope", &extras).unwrap();
|
||||
let (h2, payload, x2) = decode_object_stream(&b).unwrap();
|
||||
assert_eq!((h2.track_alias, h2.group_id, h2.object_id, h2.publisher_priority), (h.track_alias, h.group_id, h.object_id, 40));
|
||||
assert_eq!((h2.mrl_layer, h2.mrl_total_layers, h2.wai_envelope, h2.payload_len), (1, 3, true, 8));
|
||||
assert_eq!(&payload[..], b"envelope");
|
||||
assert_eq!(x2, extras);
|
||||
let bad = ObjectHeader { status: ObjectStatus::DoesNotExist, ..h };
|
||||
assert!(encode_object_stream(&bad, b"", &ObjectExtras { receipt: None, wai_receipt: None, delivery_timeout_ms: None }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_rides_its_receipts_track() {
|
||||
let (o, g) = signed(Some(vec![0]), 0);
|
||||
let bundle = ReceiptBundle { group: g, objects: vec![o] };
|
||||
let b = bundle.encode_stream(11, 9, 40).unwrap();
|
||||
let (sh, back) = ReceiptBundle::decode_stream(&b).unwrap();
|
||||
assert_eq!((sh.track_alias, sh.group_id, sh.publisher_priority), (11, 9, Some(40)));
|
||||
assert_eq!(back, bundle);
|
||||
}
|
||||
}
|
||||
78
joule-code/joule-code-rs/crates/joule-moq/src/draft.rs
Normal file
78
joule-code/joule-code-rs/crates/joule-moq/src/draft.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
//! Which MOQT draft a session speaks.
|
||||
//!
|
||||
//! Since -15 a draft is negotiated before the first MOQT byte: by ALPN on
|
||||
//! native QUIC and by `WT-Available-Protocols` on WebTransport, each draft
|
||||
//! named `moqt-NN` (`draft-ietf-moq-transport-22` §6.2). No two drafts
|
||||
//! interoperate, so a session is one draft from its first byte to its last.
|
||||
//!
|
||||
//! This crate implements -22. A draft added later (or an earlier one, for
|
||||
//! interoperation with a deployed relay) is a codec module of its own beside
|
||||
//! [`crate::draft22`] and a variant here; nothing that selects by [`Draft`]
|
||||
//! changes shape.
|
||||
|
||||
/// A MOQT draft this crate can speak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Draft {
|
||||
/// `draft-ietf-moq-transport-22`, 1 October 2026.
|
||||
D22,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
/// Every draft this crate speaks, most preferred first.
|
||||
pub const SUPPORTED: &'static [Draft] = &[Draft::D22];
|
||||
|
||||
/// The draft number.
|
||||
pub const fn number(self) -> u32 {
|
||||
match self {
|
||||
Draft::D22 => 22,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ALPN token (and WebTransport protocol) that selects it.
|
||||
pub const fn alpn(self) -> &'static [u8] {
|
||||
match self {
|
||||
Draft::D22 => crate::draft22::ALPN,
|
||||
}
|
||||
}
|
||||
|
||||
/// The conventional 32-bit code of the draft, `0xff000000 + NN`.
|
||||
pub const fn version_code(self) -> u32 {
|
||||
0xff00_0000 + self.number()
|
||||
}
|
||||
|
||||
pub fn from_alpn(alpn: &[u8]) -> Option<Draft> {
|
||||
Self::SUPPORTED.iter().copied().find(|d| d.alpn() == alpn)
|
||||
}
|
||||
|
||||
pub fn from_version_code(code: u32) -> Option<Draft> {
|
||||
Self::SUPPORTED.iter().copied().find(|d| d.version_code() == code)
|
||||
}
|
||||
|
||||
/// The first draft of `offered` (in the peer's order of preference) that this
|
||||
/// crate speaks, for a server choosing among a client's ALPNs.
|
||||
pub fn select<'a>(offered: impl IntoIterator<Item = &'a [u8]>) -> Option<Draft> {
|
||||
offered.into_iter().find_map(Draft::from_alpn)
|
||||
}
|
||||
|
||||
/// The ALPN list a client offers, most preferred first.
|
||||
pub fn alpn_list() -> Vec<Vec<u8>> {
|
||||
Self::SUPPORTED.iter().map(|d| d.alpn().to_vec()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draft_22_by_alpn_and_code() {
|
||||
assert_eq!(Draft::D22.alpn(), b"moqt-22");
|
||||
assert_eq!(Draft::D22.version_code(), 0xff00_0016);
|
||||
assert_eq!(Draft::from_alpn(b"moqt-22"), Some(Draft::D22));
|
||||
assert_eq!(Draft::from_alpn(b"moqt-18"), None);
|
||||
assert_eq!(Draft::from_alpn(b"moq-00"), None);
|
||||
let offered: [&[u8]; 3] = [b"moqt-18", b"h3", b"moqt-22"];
|
||||
assert_eq!(Draft::select(offered), Some(Draft::D22));
|
||||
assert_eq!(Draft::from_version_code(crate::draft22::VERSION), Some(Draft::D22));
|
||||
}
|
||||
}
|
||||
163
joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs
Normal file
163
joule-code/joule-code-rs/crates/joule-moq/src/draft22/codes.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! Status and error codes of `draft-ietf-moq-transport-22` §12 and §16.11.
|
||||
//!
|
||||
//! Each registry is a newtype over the wire value, so a code this crate does
|
||||
//! not name still round-trips. §13 requires an unknown code in any error
|
||||
//! context to be treated as `INTERNAL_ERROR` for that context and never, on its
|
||||
//! own, to close the session: [`SessionError::effective`] and its siblings
|
||||
//! return that reading.
|
||||
|
||||
macro_rules! code_registry {
|
||||
(
|
||||
$(#[$meta:meta])*
|
||||
$name:ident, internal = $internal:expr, { $( $(#[$cm:meta])* $konst:ident = $val:expr ),* $(,)? }
|
||||
) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
pub struct $name(pub u64);
|
||||
|
||||
impl $name {
|
||||
$( $(#[$cm])* pub const $konst: $name = $name($val); )*
|
||||
|
||||
/// Every code this draft registers, with its name.
|
||||
pub const REGISTERED: &'static [($name, &'static str)] = &[ $( ($name($val), stringify!($konst)) ),* ];
|
||||
|
||||
/// The registered name, or `None` for a code this draft does not register.
|
||||
pub fn name(self) -> Option<&'static str> {
|
||||
Self::REGISTERED.iter().find(|(c, _)| *c == self).map(|(_, n)| *n)
|
||||
}
|
||||
|
||||
/// §13: a code this draft does not register reads as `INTERNAL_ERROR`.
|
||||
pub fn effective(self) -> $name {
|
||||
if self.name().is_some() { self } else { $internal }
|
||||
}
|
||||
|
||||
/// Whether the value is a §13 grease value (`0x7f * N + 0x9D`).
|
||||
pub fn is_grease(self) -> bool {
|
||||
is_grease(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.name() {
|
||||
Some(n) => f.write_str(n),
|
||||
None => write!(f, "{:#x}", self.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether `v` follows §13's grease pattern `0x7f * N + 0x9D`.
|
||||
pub fn is_grease(v: u64) -> bool {
|
||||
v >= 0x9d && (v - 0x9d).is_multiple_of(0x7f)
|
||||
}
|
||||
|
||||
code_registry! {
|
||||
/// Session termination codes (§12.2, Table 19).
|
||||
SessionError, internal = SessionError::INTERNAL_ERROR, {
|
||||
NO_ERROR = 0x0,
|
||||
INTERNAL_ERROR = 0x1,
|
||||
UNAUTHORIZED = 0x2,
|
||||
PROTOCOL_VIOLATION = 0x3,
|
||||
INVALID_REQUEST_ID = 0x4,
|
||||
DUPLICATE_TRACK_ALIAS = 0x5,
|
||||
KEY_VALUE_FORMATTING_ERROR = 0x6,
|
||||
INVALID_PATH = 0x8,
|
||||
MALFORMED_PATH = 0x9,
|
||||
GOAWAY_TIMEOUT = 0x10,
|
||||
CONTROL_MESSAGE_TIMEOUT = 0x11,
|
||||
DATA_STREAM_TIMEOUT = 0x12,
|
||||
AUTH_TOKEN_CACHE_OVERFLOW = 0x13,
|
||||
DUPLICATE_AUTH_TOKEN_ALIAS = 0x14,
|
||||
MALFORMED_AUTH_TOKEN = 0x16,
|
||||
UNKNOWN_AUTH_TOKEN_ALIAS = 0x17,
|
||||
EXPIRED_AUTH_TOKEN = 0x18,
|
||||
INVALID_AUTHORITY = 0x19,
|
||||
MALFORMED_AUTHORITY = 0x1a,
|
||||
TOO_MANY_REQUEST_UPDATES = 0x1b,
|
||||
}
|
||||
}
|
||||
|
||||
code_registry! {
|
||||
/// REQUEST_ERROR codes (§12.3, Table 20).
|
||||
RequestErrorCode, internal = RequestErrorCode::INTERNAL_ERROR, {
|
||||
INTERNAL_ERROR = 0x0,
|
||||
UNAUTHORIZED = 0x1,
|
||||
TIMEOUT = 0x2,
|
||||
NOT_SUPPORTED = 0x3,
|
||||
MALFORMED_AUTH_TOKEN = 0x4,
|
||||
EXPIRED_AUTH_TOKEN = 0x5,
|
||||
GOING_AWAY = 0x6,
|
||||
EXCESSIVE_LOAD = 0x9,
|
||||
DOES_NOT_EXIST = 0x10,
|
||||
INVALID_RANGE = 0x11,
|
||||
MALFORMED_TRACK = 0x12,
|
||||
UNINTERESTED = 0x20,
|
||||
PREFIX_OVERLAP = 0x30,
|
||||
NAMESPACE_TOO_LARGE = 0x31,
|
||||
UNSUPPORTED_EXTENSION = 0x33,
|
||||
REDIRECT = 0x34,
|
||||
CONFLICTING_FILTERS = 0x35,
|
||||
INVALID_FILTER = 0x36,
|
||||
}
|
||||
}
|
||||
|
||||
code_registry! {
|
||||
/// PUBLISH_DONE status codes (§12.4, Table 21).
|
||||
PublishDoneCode, internal = PublishDoneCode::INTERNAL_ERROR, {
|
||||
INTERNAL_ERROR = 0x0,
|
||||
UNAUTHORIZED = 0x1,
|
||||
TRACK_ENDED = 0x2,
|
||||
GOING_AWAY = 0x4,
|
||||
TOO_FAR_BEHIND = 0x5,
|
||||
EXPIRED = 0x6,
|
||||
UPDATE_FAILED = 0x8,
|
||||
EXCESSIVE_LOAD = 0x9,
|
||||
MALFORMED_TRACK = 0x12,
|
||||
}
|
||||
}
|
||||
|
||||
code_registry! {
|
||||
/// Stream reset error codes (§12.5, Table 22).
|
||||
StreamResetCode, internal = StreamResetCode::INTERNAL_ERROR, {
|
||||
INTERNAL_ERROR = 0x0,
|
||||
CANCELLED = 0x1,
|
||||
DELIVERY_TIMEOUT = 0x2,
|
||||
SESSION_CLOSED = 0x3,
|
||||
GOING_AWAY = 0x4,
|
||||
TOO_FAR_BEHIND = 0x5,
|
||||
UNKNOWN_OBJECT_STATUS = 0x6,
|
||||
EXPIRED_AUTH_TOKEN = 0x7,
|
||||
EXCESSIVE_LOAD = 0x9,
|
||||
MALFORMED_TRACK = 0x12,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_codes_read_as_internal_error() {
|
||||
assert_eq!(SessionError(0x7).effective(), SessionError::INTERNAL_ERROR);
|
||||
assert_eq!(SessionError::PROTOCOL_VIOLATION.effective(), SessionError::PROTOCOL_VIOLATION);
|
||||
assert_eq!(RequestErrorCode(0x9d).effective(), RequestErrorCode::INTERNAL_ERROR);
|
||||
assert_eq!(PublishDoneCode(0x3).effective(), PublishDoneCode::INTERNAL_ERROR);
|
||||
assert_eq!(StreamResetCode(0x8).effective(), StreamResetCode::INTERNAL_ERROR);
|
||||
assert_eq!(RequestErrorCode::REDIRECT.to_string(), "REDIRECT");
|
||||
assert_eq!(SessionError(0x7).to_string(), "0x7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grease_follows_the_pattern() {
|
||||
assert!(is_grease(0x9d) && is_grease(0x11c) && is_grease(0x3fff_ffff_ffff_ffde));
|
||||
assert!(!is_grease(0x9c) && !is_grease(0x9e) && !is_grease(0));
|
||||
for (c, _) in SessionError::REGISTERED {
|
||||
assert!(!c.is_grease(), "{c}");
|
||||
}
|
||||
for (c, _) in RequestErrorCode::REGISTERED {
|
||||
assert!(!c.is_grease(), "{c}");
|
||||
}
|
||||
}
|
||||
}
|
||||
586
joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs
Normal file
586
joule-code/joule-code-rs/crates/joule-moq/src/draft22/control.rs
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
//! Control messages (§9, Table 5).
|
||||
//!
|
||||
//! Every message on a control or request stream is `Type (vi64)`, `Length
|
||||
//! (16)` and a body of exactly Length bytes. SETUP opens each endpoint's
|
||||
//! unidirectional control stream; GOAWAY may follow it there. Every other
|
||||
//! message is on a bidirectional request stream; [`super::request`] holds the
|
||||
//! rules of which message may come where.
|
||||
|
||||
use super::codes::{PublishDoneCode, RequestErrorCode};
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::io::{put_length_prefixed, put_varint, Reader};
|
||||
use super::kvp::{Properties, SetupOptions};
|
||||
use super::names::{decode_namespace, decode_track_name, encode_namespace, encode_track_name, Location};
|
||||
use super::params::{ctx, ParamContext, Parameters};
|
||||
use super::request::{Perspective, RequestKind};
|
||||
use crate::track::{TrackName, TrackNamespace};
|
||||
|
||||
/// Message types (Table 5).
|
||||
pub mod msg {
|
||||
pub const REQUEST_UPDATE: u64 = 0x02;
|
||||
pub const SUBSCRIBE: u64 = 0x03;
|
||||
pub const SUBSCRIBE_OK: u64 = 0x04;
|
||||
pub const REQUEST_ERROR: u64 = 0x05;
|
||||
pub const PUBLISH_NAMESPACE: u64 = 0x06;
|
||||
pub const REQUEST_OK: u64 = 0x07;
|
||||
pub const NAMESPACE: u64 = 0x08;
|
||||
pub const PUBLISH_DONE: u64 = 0x0b;
|
||||
pub const TRACK_STATUS: u64 = 0x0d;
|
||||
pub const NAMESPACE_DONE: u64 = 0x0e;
|
||||
pub const PUBLISH_SKIPPED: u64 = 0x0f;
|
||||
pub const GOAWAY: u64 = 0x10;
|
||||
pub const FETCH: u64 = 0x16;
|
||||
pub const FETCH_OK: u64 = 0x18;
|
||||
pub const PUBLISH: u64 = 0x1d;
|
||||
pub const PUBLISH_STATE_NOTIFY: u64 = 0x22;
|
||||
pub const SUBSCRIBE_NAMESPACE: u64 = 0x50;
|
||||
pub const SUBSCRIBE_TRACKS: u64 = 0x51;
|
||||
pub const SETUP: u64 = 0x2f00;
|
||||
|
||||
/// Types earlier drafts used, reserved in -22 (Table 5): an endpoint that
|
||||
/// receives one has received an unknown message.
|
||||
pub const RESERVED: [u64; 6] = [0x01, 0x40, 0x41, 0x20, 0x21, 0x1e];
|
||||
|
||||
pub fn name(t: u64) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
REQUEST_UPDATE => "REQUEST_UPDATE",
|
||||
SUBSCRIBE => "SUBSCRIBE",
|
||||
SUBSCRIBE_OK => "SUBSCRIBE_OK",
|
||||
REQUEST_ERROR => "REQUEST_ERROR",
|
||||
PUBLISH_NAMESPACE => "PUBLISH_NAMESPACE",
|
||||
REQUEST_OK => "REQUEST_OK",
|
||||
NAMESPACE => "NAMESPACE",
|
||||
PUBLISH_DONE => "PUBLISH_DONE",
|
||||
TRACK_STATUS => "TRACK_STATUS",
|
||||
NAMESPACE_DONE => "NAMESPACE_DONE",
|
||||
PUBLISH_SKIPPED => "PUBLISH_SKIPPED",
|
||||
GOAWAY => "GOAWAY",
|
||||
FETCH => "FETCH",
|
||||
FETCH_OK => "FETCH_OK",
|
||||
PUBLISH => "PUBLISH",
|
||||
PUBLISH_STATE_NOTIFY => "PUBLISH_STATE_NOTIFY",
|
||||
SUBSCRIBE_NAMESPACE => "SUBSCRIBE_NAMESPACE",
|
||||
SUBSCRIBE_TRACKS => "SUBSCRIBE_TRACKS",
|
||||
SETUP => "SETUP",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest message body (Length is 16 bits).
|
||||
pub const MAX_MESSAGE_BODY: u64 = 0xffff;
|
||||
/// The largest Reason Phrase (§8.5).
|
||||
pub const MAX_REASON_PHRASE: u64 = 1024;
|
||||
/// The largest New Session URI in GOAWAY (§9.2).
|
||||
pub const MAX_GOAWAY_URI: u64 = 8192;
|
||||
|
||||
/// SETUP (§9.1): the first message on each endpoint's control stream.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Setup {
|
||||
pub options: SetupOptions,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
/// The checks that depend on who sent it (§9.1.1, §9.1.2, §9.1.4): a server
|
||||
/// sends no PATH or AUTHORITY, and a client's SETUP registers or uses
|
||||
/// tokens only by value or REGISTER. The session closes with INVALID_PATH,
|
||||
/// INVALID_AUTHORITY or PROTOCOL_VIOLATION; the code is in the returned
|
||||
/// [`super::codes::SessionError`].
|
||||
pub fn check_from(&self, sender: Perspective, over_webtransport: bool) -> Result<(), super::codes::SessionError> {
|
||||
use super::codes::SessionError as E;
|
||||
use super::token::Token;
|
||||
if self.options.path().is_some() && (sender == Perspective::Server || over_webtransport) {
|
||||
return Err(E::INVALID_PATH);
|
||||
}
|
||||
if self.options.authority().is_some() && (sender == Perspective::Server || over_webtransport) {
|
||||
return Err(E::INVALID_AUTHORITY);
|
||||
}
|
||||
if sender == Perspective::Client
|
||||
&& self.options.authorization_tokens().iter().any(|t| matches!(t, Token::Delete { .. } | Token::UseAlias { .. }))
|
||||
{
|
||||
return Err(E::PROTOCOL_VIOLATION);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// GOAWAY (§9.2).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Goaway {
|
||||
/// Empty: reuse the current URI. A client always sends it empty.
|
||||
pub new_session_uri: Vec<u8>,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Goaway {
|
||||
/// A server that receives a GOAWAY with a URI closes the session with
|
||||
/// PROTOCOL_VIOLATION (§9.2).
|
||||
pub fn check_from(&self, sender: Perspective) -> WireResult<()> {
|
||||
if sender == Perspective::Client && !self.new_session_uri.is_empty() {
|
||||
return Err(WireError::UnexpectedMessage("a client's GOAWAY names a new session URI"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// SUBSCRIBE (§9.6), FETCH (§9.11) and TRACK_STATUS (§9.13): a request for one
|
||||
/// track.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TrackRequest {
|
||||
pub request_id: u64,
|
||||
pub namespace: TrackNamespace,
|
||||
pub name: TrackName,
|
||||
pub params: Parameters,
|
||||
}
|
||||
|
||||
pub type Subscribe = TrackRequest;
|
||||
pub type Fetch = TrackRequest;
|
||||
pub type TrackStatus = TrackRequest;
|
||||
|
||||
/// SUBSCRIBE_OK (§9.7).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SubscribeOk {
|
||||
pub track_alias: u64,
|
||||
pub params: Parameters,
|
||||
pub track_properties: Properties,
|
||||
}
|
||||
|
||||
/// PUBLISH (§9.8).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Publish {
|
||||
pub request_id: u64,
|
||||
pub namespace: TrackNamespace,
|
||||
pub name: TrackName,
|
||||
pub track_alias: u64,
|
||||
pub params: Parameters,
|
||||
pub track_properties: Properties,
|
||||
}
|
||||
|
||||
/// PUBLISH_DONE (§9.9).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishDone {
|
||||
pub status: PublishDoneCode,
|
||||
/// Data streams opened for the subscription; 2^64-1 when not known exactly.
|
||||
pub stream_count: u64,
|
||||
/// The Reason Phrase (§8.5): UTF-8 by the draft, kept as bytes.
|
||||
pub reason: Vec<u8>,
|
||||
}
|
||||
|
||||
/// FETCH_OK (§9.12).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FetchOk {
|
||||
pub end_of_track: bool,
|
||||
pub end_location: Location,
|
||||
pub params: Parameters,
|
||||
pub track_properties: Properties,
|
||||
}
|
||||
|
||||
/// PUBLISH_NAMESPACE (§9.14), SUBSCRIBE_NAMESPACE (§9.15) and SUBSCRIBE_TRACKS
|
||||
/// (§9.18): a request over a Track Namespace Prefix.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NamespaceRequest {
|
||||
pub request_id: u64,
|
||||
pub prefix: TrackNamespace,
|
||||
pub params: Parameters,
|
||||
}
|
||||
|
||||
/// PUBLISH_SKIPPED (§9.19).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishSkipped {
|
||||
pub suffix: TrackNamespace,
|
||||
pub name: TrackName,
|
||||
}
|
||||
|
||||
/// REQUEST_UPDATE (§9.5).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RequestUpdate {
|
||||
pub request_id: u64,
|
||||
pub params: Parameters,
|
||||
}
|
||||
|
||||
/// REQUEST_OK (§9.3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RequestOk {
|
||||
pub params: Parameters,
|
||||
pub track_properties: Properties,
|
||||
}
|
||||
|
||||
/// The Redirect structure of a REQUEST_ERROR with code REDIRECT (§9.4.1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Redirect {
|
||||
pub connect_uri: Vec<u8>,
|
||||
pub namespace: TrackNamespace,
|
||||
pub name: TrackName,
|
||||
}
|
||||
|
||||
/// REQUEST_ERROR (§9.4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RequestError {
|
||||
pub code: RequestErrorCode,
|
||||
/// Milliseconds before a retry, plus one; 0 is "do not retry".
|
||||
pub retry_interval: u64,
|
||||
pub reason: Vec<u8>,
|
||||
/// Present exactly when `code` is REDIRECT.
|
||||
pub redirect: Option<Redirect>,
|
||||
}
|
||||
|
||||
/// One control message.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ControlMessage {
|
||||
Setup(Setup),
|
||||
Goaway(Goaway),
|
||||
Subscribe(TrackRequest),
|
||||
SubscribeOk(SubscribeOk),
|
||||
PublishStateNotify(Parameters),
|
||||
Publish(Publish),
|
||||
PublishDone(PublishDone),
|
||||
Fetch(TrackRequest),
|
||||
FetchOk(FetchOk),
|
||||
TrackStatus(TrackRequest),
|
||||
PublishNamespace(NamespaceRequest),
|
||||
SubscribeNamespace(NamespaceRequest),
|
||||
SubscribeTracks(NamespaceRequest),
|
||||
/// NAMESPACE (§9.16): a Track Namespace Suffix.
|
||||
Namespace(TrackNamespace),
|
||||
/// NAMESPACE_DONE (§9.17): a Track Namespace Suffix.
|
||||
NamespaceDone(TrackNamespace),
|
||||
PublishSkipped(PublishSkipped),
|
||||
RequestUpdate(RequestUpdate),
|
||||
RequestOk(RequestOk),
|
||||
RequestError(RequestError),
|
||||
}
|
||||
|
||||
fn reason_phrase(r: &mut Reader<'_>) -> WireResult<Vec<u8>> {
|
||||
Ok(r.length_prefixed(Some(MAX_REASON_PHRASE), "reason phrase")?.to_vec())
|
||||
}
|
||||
|
||||
fn track_properties(r: &mut Reader<'_>) -> WireResult<Properties> {
|
||||
let p = Properties::decode(r.rest(), "track properties")?;
|
||||
p.check_track()?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
impl ControlMessage {
|
||||
pub fn type_code(&self) -> u64 {
|
||||
match self {
|
||||
ControlMessage::Setup(_) => msg::SETUP,
|
||||
ControlMessage::Goaway(_) => msg::GOAWAY,
|
||||
ControlMessage::Subscribe(_) => msg::SUBSCRIBE,
|
||||
ControlMessage::SubscribeOk(_) => msg::SUBSCRIBE_OK,
|
||||
ControlMessage::PublishStateNotify(_) => msg::PUBLISH_STATE_NOTIFY,
|
||||
ControlMessage::Publish(_) => msg::PUBLISH,
|
||||
ControlMessage::PublishDone(_) => msg::PUBLISH_DONE,
|
||||
ControlMessage::Fetch(_) => msg::FETCH,
|
||||
ControlMessage::FetchOk(_) => msg::FETCH_OK,
|
||||
ControlMessage::TrackStatus(_) => msg::TRACK_STATUS,
|
||||
ControlMessage::PublishNamespace(_) => msg::PUBLISH_NAMESPACE,
|
||||
ControlMessage::SubscribeNamespace(_) => msg::SUBSCRIBE_NAMESPACE,
|
||||
ControlMessage::SubscribeTracks(_) => msg::SUBSCRIBE_TRACKS,
|
||||
ControlMessage::Namespace(_) => msg::NAMESPACE,
|
||||
ControlMessage::NamespaceDone(_) => msg::NAMESPACE_DONE,
|
||||
ControlMessage::PublishSkipped(_) => msg::PUBLISH_SKIPPED,
|
||||
ControlMessage::RequestUpdate(_) => msg::REQUEST_UPDATE,
|
||||
ControlMessage::RequestOk(_) => msg::REQUEST_OK,
|
||||
ControlMessage::RequestError(_) => msg::REQUEST_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
msg::name(self.type_code()).expect("every variant is named")
|
||||
}
|
||||
|
||||
/// The Request ID a request message consumes (§6.4.2.1); `None` for a
|
||||
/// response.
|
||||
pub fn request_id(&self) -> Option<u64> {
|
||||
match self {
|
||||
ControlMessage::Subscribe(r) | ControlMessage::Fetch(r) | ControlMessage::TrackStatus(r) => Some(r.request_id),
|
||||
ControlMessage::Publish(p) => Some(p.request_id),
|
||||
ControlMessage::PublishNamespace(r) | ControlMessage::SubscribeNamespace(r) | ControlMessage::SubscribeTracks(r) => Some(r.request_id),
|
||||
ControlMessage::RequestUpdate(u) => Some(u.request_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The request a message opens a request stream with (§6.3), if it is one.
|
||||
pub fn opens_request(&self) -> Option<RequestKind> {
|
||||
Some(match self {
|
||||
ControlMessage::Subscribe(_) => RequestKind::Subscribe,
|
||||
ControlMessage::Publish(_) => RequestKind::Publish,
|
||||
ControlMessage::Fetch(_) => RequestKind::Fetch,
|
||||
ControlMessage::TrackStatus(_) => RequestKind::TrackStatus,
|
||||
ControlMessage::PublishNamespace(_) => RequestKind::PublishNamespace,
|
||||
ControlMessage::SubscribeNamespace(_) => RequestKind::SubscribeNamespace,
|
||||
ControlMessage::SubscribeTracks(_) => RequestKind::SubscribeTracks,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The message, framed: Type, Length and body.
|
||||
pub fn encode(&self) -> WireResult<Vec<u8>> {
|
||||
let mut body = Vec::new();
|
||||
self.encode_body(&mut body)?;
|
||||
if body.len() as u64 > MAX_MESSAGE_BODY {
|
||||
return Err(WireError::LengthExceedsLimit { what: "control message", len: body.len() as u64, limit: MAX_MESSAGE_BODY });
|
||||
}
|
||||
let mut out = Vec::with_capacity(body.len() + 6);
|
||||
put_varint(&mut out, self.type_code());
|
||||
out.extend_from_slice(&(body.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_body(&self, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
let req = |r: &TrackRequest, c: &ParamContext, out: &mut Vec<u8>| -> WireResult<()> {
|
||||
put_varint(out, r.request_id);
|
||||
encode_namespace(&r.namespace, out)?;
|
||||
encode_track_name(&r.namespace, &r.name, out)?;
|
||||
r.params.encode_counted(out, Some(c))
|
||||
};
|
||||
let nsreq = |r: &NamespaceRequest, c: &ParamContext, out: &mut Vec<u8>| -> WireResult<()> {
|
||||
put_varint(out, r.request_id);
|
||||
encode_namespace(&r.prefix, out)?;
|
||||
r.params.encode_counted(out, Some(c))
|
||||
};
|
||||
match self {
|
||||
ControlMessage::Setup(s) => s.options.encode(out)?,
|
||||
ControlMessage::Goaway(g) => {
|
||||
put_length_prefixed(out, &g.new_session_uri, Some(MAX_GOAWAY_URI), "GOAWAY new session URI")?;
|
||||
put_varint(out, g.timeout_ms);
|
||||
}
|
||||
ControlMessage::Subscribe(r) => req(r, &ctx::SUBSCRIBE, out)?,
|
||||
ControlMessage::Fetch(r) => req(r, &ctx::FETCH, out)?,
|
||||
ControlMessage::TrackStatus(r) => req(r, &ctx::TRACK_STATUS, out)?,
|
||||
ControlMessage::SubscribeOk(s) => {
|
||||
put_varint(out, s.track_alias);
|
||||
s.params.encode_counted(out, Some(&ctx::SUBSCRIBE_OK))?;
|
||||
s.track_properties.encode(out)?;
|
||||
}
|
||||
ControlMessage::PublishStateNotify(p) => p.encode_counted(out, Some(&ctx::PUBLISH_STATE_NOTIFY))?,
|
||||
ControlMessage::Publish(p) => {
|
||||
put_varint(out, p.request_id);
|
||||
encode_namespace(&p.namespace, out)?;
|
||||
encode_track_name(&p.namespace, &p.name, out)?;
|
||||
put_varint(out, p.track_alias);
|
||||
p.params.encode_counted(out, Some(&ctx::PUBLISH))?;
|
||||
p.track_properties.encode(out)?;
|
||||
}
|
||||
ControlMessage::PublishDone(d) => {
|
||||
put_varint(out, d.status.0);
|
||||
put_varint(out, d.stream_count);
|
||||
put_length_prefixed(out, &d.reason, Some(MAX_REASON_PHRASE), "reason phrase")?;
|
||||
}
|
||||
ControlMessage::FetchOk(f) => {
|
||||
out.push(f.end_of_track as u8);
|
||||
f.end_location.encode(out);
|
||||
f.params.encode_counted(out, Some(&ctx::FETCH_OK))?;
|
||||
f.track_properties.encode(out)?;
|
||||
}
|
||||
ControlMessage::PublishNamespace(r) => nsreq(r, &ctx::PUBLISH_NAMESPACE, out)?,
|
||||
ControlMessage::SubscribeNamespace(r) => nsreq(r, &ctx::SUBSCRIBE_NAMESPACE, out)?,
|
||||
ControlMessage::SubscribeTracks(r) => nsreq(r, &ctx::SUBSCRIBE_TRACKS, out)?,
|
||||
ControlMessage::Namespace(ns) | ControlMessage::NamespaceDone(ns) => encode_namespace(ns, out)?,
|
||||
ControlMessage::PublishSkipped(s) => {
|
||||
encode_namespace(&s.suffix, out)?;
|
||||
encode_track_name(&s.suffix, &s.name, out)?;
|
||||
}
|
||||
ControlMessage::RequestUpdate(u) => {
|
||||
put_varint(out, u.request_id);
|
||||
u.params.encode_counted(out, Some(&ctx::REQUEST_UPDATE))?;
|
||||
}
|
||||
ControlMessage::RequestOk(o) => {
|
||||
o.params.encode_counted(out, Some(&ctx::REQUEST_OK))?;
|
||||
o.track_properties.encode(out)?;
|
||||
}
|
||||
ControlMessage::RequestError(e) => {
|
||||
if e.redirect.is_some() != (e.code == RequestErrorCode::REDIRECT) {
|
||||
return Err(WireError::Unencodable { what: "REQUEST_ERROR", reason: "a Redirect exactly when the code is REDIRECT" });
|
||||
}
|
||||
put_varint(out, e.code.0);
|
||||
put_varint(out, e.retry_interval);
|
||||
put_length_prefixed(out, &e.reason, Some(MAX_REASON_PHRASE), "reason phrase")?;
|
||||
if let Some(rd) = &e.redirect {
|
||||
put_varint(out, rd.connect_uri.len() as u64);
|
||||
out.extend_from_slice(&rd.connect_uri);
|
||||
encode_namespace(&rd.namespace, out)?;
|
||||
encode_track_name(&rd.namespace, &rd.name, out)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one framed message from the front of `r`: its type (refused at once
|
||||
/// when this draft does not define it), its 16-bit Length, then exactly
|
||||
/// that many bytes of body.
|
||||
pub fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
let t = r.varint("message type")?;
|
||||
if msg::name(t).is_none() {
|
||||
return Err(WireError::UnknownMessageType(t));
|
||||
}
|
||||
let len = r.u16("message length")?;
|
||||
let mut b = r.sub(len as u64, "message body")?;
|
||||
let m = Self::decode_body(t, &mut b)?;
|
||||
b.finish(msg::name(t).unwrap_or("message"))?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Decode a buffer that holds exactly one framed message.
|
||||
pub fn decode_exact(buf: &[u8]) -> WireResult<Self> {
|
||||
let mut r = Reader::new(buf);
|
||||
let m = Self::decode(&mut r)?;
|
||||
r.finish("control message framing")?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn decode_body(t: u64, r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
let req = |r: &mut Reader<'_>, c: &ParamContext| -> WireResult<TrackRequest> {
|
||||
let request_id = r.varint("request id")?;
|
||||
let namespace = decode_namespace(r)?;
|
||||
let name = decode_track_name(r, &namespace)?;
|
||||
let params = Parameters::decode_counted(r, c)?;
|
||||
Ok(TrackRequest { request_id, namespace, name, params })
|
||||
};
|
||||
let nsreq = |r: &mut Reader<'_>, c: &ParamContext| -> WireResult<NamespaceRequest> {
|
||||
let request_id = r.varint("request id")?;
|
||||
let prefix = decode_namespace(r)?;
|
||||
let params = Parameters::decode_counted(r, c)?;
|
||||
Ok(NamespaceRequest { request_id, prefix, params })
|
||||
};
|
||||
Ok(match t {
|
||||
msg::SETUP => ControlMessage::Setup(Setup { options: SetupOptions::decode(r)? }),
|
||||
msg::GOAWAY => ControlMessage::Goaway(Goaway {
|
||||
new_session_uri: r.length_prefixed(Some(MAX_GOAWAY_URI), "GOAWAY new session URI")?.to_vec(),
|
||||
timeout_ms: r.varint("GOAWAY timeout")?,
|
||||
}),
|
||||
msg::SUBSCRIBE => ControlMessage::Subscribe(req(r, &ctx::SUBSCRIBE)?),
|
||||
msg::FETCH => ControlMessage::Fetch(req(r, &ctx::FETCH)?),
|
||||
msg::TRACK_STATUS => ControlMessage::TrackStatus(req(r, &ctx::TRACK_STATUS)?),
|
||||
msg::SUBSCRIBE_OK => {
|
||||
let track_alias = r.varint("track alias")?;
|
||||
let params = Parameters::decode_counted(r, &ctx::SUBSCRIBE_OK)?;
|
||||
ControlMessage::SubscribeOk(SubscribeOk { track_alias, params, track_properties: track_properties(r)? })
|
||||
}
|
||||
msg::PUBLISH_STATE_NOTIFY => ControlMessage::PublishStateNotify(Parameters::decode_counted(r, &ctx::PUBLISH_STATE_NOTIFY)?),
|
||||
msg::PUBLISH => {
|
||||
let request_id = r.varint("request id")?;
|
||||
let namespace = decode_namespace(r)?;
|
||||
let name = decode_track_name(r, &namespace)?;
|
||||
let track_alias = r.varint("track alias")?;
|
||||
let params = Parameters::decode_counted(r, &ctx::PUBLISH)?;
|
||||
ControlMessage::Publish(Publish { request_id, namespace, name, track_alias, params, track_properties: track_properties(r)? })
|
||||
}
|
||||
msg::PUBLISH_DONE => ControlMessage::PublishDone(PublishDone {
|
||||
status: PublishDoneCode(r.varint("status code")?),
|
||||
stream_count: r.varint("stream count")?,
|
||||
reason: reason_phrase(r)?,
|
||||
}),
|
||||
msg::FETCH_OK => {
|
||||
let end_of_track = match r.u8("end of track")? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
v => return Err(WireError::InvalidValue { what: "FETCH_OK End Of Track", value: v as u64 }),
|
||||
};
|
||||
let end_location = Location::decode(r, "end location")?;
|
||||
let params = Parameters::decode_counted(r, &ctx::FETCH_OK)?;
|
||||
ControlMessage::FetchOk(FetchOk { end_of_track, end_location, params, track_properties: track_properties(r)? })
|
||||
}
|
||||
msg::PUBLISH_NAMESPACE => ControlMessage::PublishNamespace(nsreq(r, &ctx::PUBLISH_NAMESPACE)?),
|
||||
msg::SUBSCRIBE_NAMESPACE => ControlMessage::SubscribeNamespace(nsreq(r, &ctx::SUBSCRIBE_NAMESPACE)?),
|
||||
msg::SUBSCRIBE_TRACKS => ControlMessage::SubscribeTracks(nsreq(r, &ctx::SUBSCRIBE_TRACKS)?),
|
||||
msg::NAMESPACE => ControlMessage::Namespace(decode_namespace(r)?),
|
||||
msg::NAMESPACE_DONE => ControlMessage::NamespaceDone(decode_namespace(r)?),
|
||||
msg::PUBLISH_SKIPPED => {
|
||||
let suffix = decode_namespace(r)?;
|
||||
let name = decode_track_name(r, &suffix)?;
|
||||
ControlMessage::PublishSkipped(PublishSkipped { suffix, name })
|
||||
}
|
||||
msg::REQUEST_UPDATE => {
|
||||
let request_id = r.varint("request id")?;
|
||||
ControlMessage::RequestUpdate(RequestUpdate { request_id, params: Parameters::decode_counted(r, &ctx::REQUEST_UPDATE)? })
|
||||
}
|
||||
msg::REQUEST_OK => {
|
||||
let params = Parameters::decode_counted(r, &ctx::REQUEST_OK)?;
|
||||
ControlMessage::RequestOk(RequestOk { params, track_properties: track_properties(r)? })
|
||||
}
|
||||
msg::REQUEST_ERROR => {
|
||||
let code = RequestErrorCode(r.varint("error code")?);
|
||||
let retry_interval = r.varint("retry interval")?;
|
||||
let reason = reason_phrase(r)?;
|
||||
let redirect = if code == RequestErrorCode::REDIRECT {
|
||||
let connect_uri = r.length_prefixed(None, "redirect connect URI")?.to_vec();
|
||||
let namespace = decode_namespace(r)?;
|
||||
let name = decode_track_name(r, &namespace)?;
|
||||
Some(Redirect { connect_uri, namespace, name })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ControlMessage::RequestError(RequestError { code, retry_interval, reason, redirect })
|
||||
}
|
||||
other => return Err(WireError::UnknownMessageType(other)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestOk {
|
||||
/// The checks §9.3 makes once the request this answers is known: its
|
||||
/// parameters, and Track Properties only in TRACK_STATUS_OK (and
|
||||
/// SUBSCRIBE_TRACKS_OK, which §9.3 does not restrict).
|
||||
pub fn check_for(&self, answers: RequestKind) -> WireResult<()> {
|
||||
let c = match answers {
|
||||
RequestKind::Publish => &ctx::PUBLISH_OK,
|
||||
RequestKind::TrackStatus => &ctx::TRACK_STATUS_OK,
|
||||
RequestKind::SubscribeNamespace | RequestKind::SubscribeTracks | RequestKind::PublishNamespace => &ctx::NAMESPACE_OK,
|
||||
RequestKind::Subscribe | RequestKind::Fetch => {
|
||||
return Err(WireError::UnexpectedMessage("REQUEST_OK answers neither SUBSCRIBE nor FETCH"));
|
||||
}
|
||||
};
|
||||
self.params.check(c)?;
|
||||
let may_carry = matches!(answers, RequestKind::TrackStatus | RequestKind::SubscribeTracks);
|
||||
if !may_carry && !self.track_properties.is_empty() {
|
||||
return Err(WireError::UnexpectedMessage("Track Properties in a REQUEST_OK that carries none"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The checks for a REQUEST_OK that answers a REQUEST_UPDATE (§9.3).
|
||||
pub fn check_for_update(&self) -> WireResult<()> {
|
||||
self.params.check(&ctx::REQUEST_UPDATE_OK)?;
|
||||
if !self.track_properties.is_empty() {
|
||||
return Err(WireError::UnexpectedMessage("Track Properties in a REQUEST_UPDATE_OK"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestUpdate {
|
||||
/// The parameters §9.5 allows once the request being updated is known.
|
||||
pub fn check_for(&self, updates: RequestKind) -> WireResult<()> {
|
||||
let c = match updates {
|
||||
RequestKind::Subscribe | RequestKind::Publish => &ctx::UPDATE_SUBSCRIPTION,
|
||||
RequestKind::Fetch => &ctx::UPDATE_FETCH,
|
||||
RequestKind::PublishNamespace => &ctx::UPDATE_PUBLISH_NAMESPACE,
|
||||
RequestKind::SubscribeNamespace => &ctx::UPDATE_SUBSCRIBE_NAMESPACE,
|
||||
RequestKind::SubscribeTracks => &ctx::UPDATE_SUBSCRIBE_TRACKS,
|
||||
RequestKind::TrackStatus => return Err(WireError::UnexpectedMessage("REQUEST_UPDATE for a TRACK_STATUS")),
|
||||
};
|
||||
self.params.check(c)
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestError {
|
||||
/// A server that receives a Redirect with a Connect URI closes the session
|
||||
/// with PROTOCOL_VIOLATION; a Redirect for a namespace-scoped request names
|
||||
/// no track (§9.4.1).
|
||||
pub fn check(&self, sender: Perspective, answers: RequestKind) -> WireResult<()> {
|
||||
if let Some(rd) = &self.redirect {
|
||||
if sender == Perspective::Client && !rd.connect_uri.is_empty() {
|
||||
return Err(WireError::UnexpectedMessage("a client's Redirect names a Connect URI"));
|
||||
}
|
||||
let ns_scoped = matches!(answers, RequestKind::SubscribeNamespace | RequestKind::PublishNamespace | RequestKind::SubscribeTracks);
|
||||
if ns_scoped && !rd.name.0.is_empty() {
|
||||
return Err(WireError::UnexpectedMessage("a Redirect for a namespace request names a track"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1173
joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs
Normal file
1173
joule-code/joule-code-rs/crates/joule-moq/src/draft22/data.rs
Normal file
File diff suppressed because it is too large
Load diff
188
joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs
Normal file
188
joule-code/joule-code-rs/crates/joule-moq/src/draft22/error.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! Every refusal of the draft-22 codec, typed.
|
||||
//!
|
||||
//! A decoder never panics on input: it returns one of these. Each carries the
|
||||
//! consequence the draft gives it ([`WireError::consequence`]) and a stable
|
||||
//! name ([`WireError::kind`]) that the vector corpus under
|
||||
//! `tests/vectors/draft-22/` and its independent checker use.
|
||||
|
||||
use super::codes::{RequestErrorCode, SessionError, StreamResetCode};
|
||||
|
||||
/// What the draft says the receiver does about a refusal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Consequence {
|
||||
/// Close the session with this code (§6.6, §12.2).
|
||||
CloseSession(SessionError),
|
||||
/// Reject the request with REQUEST_ERROR and this code (§12.3).
|
||||
RejectRequest(RequestErrorCode),
|
||||
/// The track is malformed (§12.1): cancel the subscription or fetch; a relay
|
||||
/// ends its downstream subscriptions with MALFORMED_TRACK.
|
||||
MalformedTrack,
|
||||
/// A limit of this implementation, not of the draft: reset the stream with
|
||||
/// this code (§15.5).
|
||||
ResetStream(StreamResetCode),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Consequence {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Consequence::CloseSession(c) => write!(f, "close the session: {c}"),
|
||||
Consequence::RejectRequest(c) => write!(f, "reject the request: {c}"),
|
||||
Consequence::MalformedTrack => f.write_str("malformed track"),
|
||||
Consequence::ResetStream(c) => write!(f, "reset the stream: {c}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal of the draft-22 codec.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum WireError {
|
||||
/// The input ends inside a field. Within a control message this is a body
|
||||
/// shorter than its Length says (§9); on a data stream, a FIN inside an
|
||||
/// object (§11.3).
|
||||
#[error("truncated: {what}")]
|
||||
Truncated { what: &'static str },
|
||||
/// Bytes remain after the last field of a length-bounded structure: a
|
||||
/// control message whose Length does not match its body (§9).
|
||||
#[error("{what}: {count} byte(s) after the last field")]
|
||||
TrailingBytes { what: &'static str, count: usize },
|
||||
/// A control message type this draft does not define (§9, Table 5),
|
||||
/// including the reserved types of earlier drafts.
|
||||
#[error("unknown control message type {0:#x}")]
|
||||
UnknownMessageType(u64),
|
||||
/// A unidirectional stream type this draft does not define (§6.4.1).
|
||||
#[error("unknown stream type {0:#x}")]
|
||||
UnknownStreamType(u64),
|
||||
/// A Type Flags or Serialization Flags value the draft forbids (§11.2.1,
|
||||
/// §11.3.1, §11.4.1).
|
||||
#[error("{what}: invalid flags {value:#x}")]
|
||||
InvalidFlags { what: &'static str, value: u64 },
|
||||
/// A Message Parameter this draft does not define (§9.20).
|
||||
#[error("unknown message parameter {0:#x}")]
|
||||
UnknownParameter(u64),
|
||||
/// A Message Parameter, or a Setup Option, repeated where its definition
|
||||
/// allows one instance (§9.1, §9.20).
|
||||
#[error("{what} {ty:#x} repeated")]
|
||||
Duplicate { what: &'static str, ty: u64 },
|
||||
/// A Message Parameter in a message whose definition does not list it
|
||||
/// (§9.20.1).
|
||||
#[error("parameter {param:#x} is not allowed in {context}")]
|
||||
ParameterNotAllowed { param: u64, context: &'static str },
|
||||
/// The same Range Filter (parameter type, SetID and property type) twice
|
||||
/// in one message (§3.3.2).
|
||||
#[error("range filter {param:#x} with SetID {set_id} repeated")]
|
||||
DuplicateFilter { param: u64, set_id: u8, property: Option<u64> },
|
||||
/// A Range Filter the draft refuses: a delta past 2^64-1, a priority above
|
||||
/// 255, an odd property type (§8.6, §9.20.10-14).
|
||||
#[error("invalid filter: {reason}")]
|
||||
InvalidFilter { reason: &'static str },
|
||||
/// A delta-encoded type past 2^64-1 (§8.3, §9.20).
|
||||
#[error("{what}: type delta past 2^64-1")]
|
||||
TypeOverflow { what: &'static str },
|
||||
/// A length past a limit the draft sets.
|
||||
#[error("{what}: length {len} exceeds the draft's limit of {limit}")]
|
||||
LengthExceedsLimit { what: &'static str, len: u64, limit: u64 },
|
||||
/// A Track Namespace of more than 32 fields (§8.7).
|
||||
#[error("track namespace of {0} fields; the limit is 32")]
|
||||
TooManyNamespaceFields(u64),
|
||||
/// A Track Namespace field of length 0 (§8.7).
|
||||
#[error("empty track namespace field")]
|
||||
EmptyNamespaceField,
|
||||
/// A field outside the values the draft allows.
|
||||
#[error("{what}: value {value:#x} is not allowed")]
|
||||
InvalidValue { what: &'static str, value: u64 },
|
||||
/// A Group ID, Object ID or Location that a delta carries past 2^64-1 or
|
||||
/// below 0 (§9.20.9, §11.3.1, §11.4.1).
|
||||
#[error("{what} past the range of a 64-bit integer")]
|
||||
Overflow { what: &'static str },
|
||||
/// Properties on an object whose status is not Normal (§11.1.2), or a
|
||||
/// non-empty payload beside one.
|
||||
#[error("properties on an object whose status is not Normal")]
|
||||
PropertiesOnNonNormal,
|
||||
/// An Object Status this draft does not register (§11.1.1).
|
||||
#[error("unknown object status {0:#x}")]
|
||||
UnknownObjectStatus(u64),
|
||||
/// A fetch object that refers to a prior object's field when there is
|
||||
/// none to refer to (§11.4.1, §11.4.1.2).
|
||||
#[error("fetch object refers to the prior object's {field}, and there is none")]
|
||||
NoPriorObject { field: &'static str },
|
||||
/// A datagram with the PROPERTIES bit and a Properties Length of 0
|
||||
/// (§11.2.1).
|
||||
#[error("datagram PROPERTIES bit with zero-length properties")]
|
||||
EmptyDatagramProperties,
|
||||
/// An Authorization Token structure that cannot be decoded (§8.9).
|
||||
#[error("authorization token cannot be decoded: {reason}")]
|
||||
MalformedToken { reason: &'static str },
|
||||
/// A message the draft forbids on the stream or at the position it came
|
||||
/// (§6.3, §6.4.2, §9).
|
||||
#[error("{0}")]
|
||||
UnexpectedMessage(&'static str),
|
||||
/// A Track is malformed (§12.1, §3.7, §10.7-10.9).
|
||||
#[error("malformed track: {reason}")]
|
||||
MalformedTrack { reason: &'static str },
|
||||
/// A session rule with a code of its own was broken: a Request ID of the
|
||||
/// wrong parity or seen before (INVALID_REQUEST_ID, §6.4.2.1), an update
|
||||
/// past MAX_REQUEST_UPDATES (TOO_MANY_REQUEST_UPDATES, §9.1.7), a Track Alias
|
||||
/// already in use (DUPLICATE_TRACK_ALIAS, §3.1.3).
|
||||
#[error("{reason} ({code})")]
|
||||
Session { code: SessionError, reason: &'static str },
|
||||
/// A value the encoder cannot put on the wire.
|
||||
#[error("cannot encode {what}: {reason}")]
|
||||
Unencodable { what: &'static str, reason: &'static str },
|
||||
/// A limit of this implementation (not the draft's) was passed.
|
||||
#[error("{what}: length {len} exceeds this implementation's limit of {limit}")]
|
||||
LocalLimit { what: &'static str, len: u64, limit: u64 },
|
||||
}
|
||||
|
||||
impl WireError {
|
||||
/// The stable name of this refusal, as the vector corpus writes it.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
WireError::Truncated { .. } => "truncated",
|
||||
WireError::TrailingBytes { .. } => "trailing-bytes",
|
||||
WireError::UnknownMessageType(_) => "unknown-message-type",
|
||||
WireError::UnknownStreamType(_) => "unknown-stream-type",
|
||||
WireError::InvalidFlags { .. } => "invalid-flags",
|
||||
WireError::UnknownParameter(_) => "unknown-parameter",
|
||||
WireError::Duplicate { .. } => "duplicate",
|
||||
WireError::ParameterNotAllowed { .. } => "parameter-not-allowed",
|
||||
WireError::DuplicateFilter { .. } => "duplicate-filter",
|
||||
WireError::InvalidFilter { .. } => "invalid-filter",
|
||||
WireError::TypeOverflow { .. } => "type-overflow",
|
||||
WireError::LengthExceedsLimit { .. } => "length-exceeds-limit",
|
||||
WireError::TooManyNamespaceFields(_) => "too-many-namespace-fields",
|
||||
WireError::EmptyNamespaceField => "empty-namespace-field",
|
||||
WireError::InvalidValue { .. } => "invalid-value",
|
||||
WireError::Overflow { .. } => "overflow",
|
||||
WireError::PropertiesOnNonNormal => "properties-on-non-normal",
|
||||
WireError::UnknownObjectStatus(_) => "unknown-object-status",
|
||||
WireError::NoPriorObject { .. } => "no-prior-object",
|
||||
WireError::EmptyDatagramProperties => "empty-datagram-properties",
|
||||
WireError::MalformedToken { .. } => "malformed-token",
|
||||
WireError::UnexpectedMessage(_) => "unexpected-message",
|
||||
WireError::MalformedTrack { .. } => "malformed-track",
|
||||
WireError::Session { .. } => "session-rule",
|
||||
WireError::Unencodable { .. } => "unencodable",
|
||||
WireError::LocalLimit { .. } => "local-limit",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the draft says the receiver does about it.
|
||||
pub fn consequence(&self) -> Consequence {
|
||||
match self {
|
||||
WireError::DuplicateFilter { .. } | WireError::InvalidFilter { .. } => {
|
||||
Consequence::RejectRequest(RequestErrorCode::INVALID_FILTER)
|
||||
}
|
||||
// §8.9 (in -22): a Token that cannot be decoded closes the session
|
||||
// with KEY_VALUE_FORMATTING_ERROR.
|
||||
WireError::MalformedToken { .. } => Consequence::CloseSession(SessionError::KEY_VALUE_FORMATTING_ERROR),
|
||||
WireError::MalformedTrack { .. } => Consequence::MalformedTrack,
|
||||
WireError::LocalLimit { .. } => Consequence::ResetStream(StreamResetCode::EXCESSIVE_LOAD),
|
||||
WireError::Session { code, .. } => Consequence::CloseSession(*code),
|
||||
// -22 gives no code for an unknown message, stream or datagram type
|
||||
// ("MUST close the session"), and PROTOCOL_VIOLATION for the rest.
|
||||
_ => Consequence::CloseSession(SessionError::PROTOCOL_VIOLATION),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type WireResult<T> = Result<T, WireError>;
|
||||
139
joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs
Normal file
139
joule-code/joule-code-rs/crates/joule-moq/src/draft22/io.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//! A bounded cursor over input bytes, and the write helpers the encoders share.
|
||||
//!
|
||||
//! Every read checks the bytes it needs are there and returns
|
||||
//! [`WireError::Truncated`] otherwise; nothing allocates from a length the input
|
||||
//! states before the bytes behind it are known to exist.
|
||||
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::varint;
|
||||
|
||||
/// A cursor over a byte slice.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Reader<'a> {
|
||||
buf: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(buf: &'a [u8]) -> Self {
|
||||
Reader { buf, pos: 0 }
|
||||
}
|
||||
|
||||
/// Bytes not yet read.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.buf.len() - self.pos
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.remaining() == 0
|
||||
}
|
||||
|
||||
/// Bytes read so far.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// The unread bytes, without consuming them.
|
||||
pub fn peek_rest(&self) -> &'a [u8] {
|
||||
&self.buf[self.pos..]
|
||||
}
|
||||
|
||||
pub fn varint(&mut self, what: &'static str) -> WireResult<u64> {
|
||||
let (v, n) = varint::get(&self.buf[self.pos..], what)?;
|
||||
self.pos += n;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn u8(&mut self, what: &'static str) -> WireResult<u8> {
|
||||
let b = *self.buf.get(self.pos).ok_or(WireError::Truncated { what })?;
|
||||
self.pos += 1;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn u16(&mut self, what: &'static str) -> WireResult<u16> {
|
||||
let b = self.bytes(2, what)?;
|
||||
Ok(u16::from_be_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
/// `n` bytes, where `n` may come from the input: refused unless they are there.
|
||||
pub fn bytes(&mut self, n: u64, what: &'static str) -> WireResult<&'a [u8]> {
|
||||
if n > self.remaining() as u64 {
|
||||
return Err(WireError::Truncated { what });
|
||||
}
|
||||
let n = n as usize;
|
||||
let s = &self.buf[self.pos..self.pos + n];
|
||||
self.pos += n;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn array32(&mut self, what: &'static str) -> WireResult<[u8; 32]> {
|
||||
let b = self.bytes(32, what)?;
|
||||
let mut a = [0u8; 32];
|
||||
a.copy_from_slice(b);
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Every unread byte.
|
||||
pub fn rest(&mut self) -> &'a [u8] {
|
||||
let s = &self.buf[self.pos..];
|
||||
self.pos = self.buf.len();
|
||||
s
|
||||
}
|
||||
|
||||
/// A reader over the next `n` bytes, which this reader then skips.
|
||||
pub fn sub(&mut self, n: u64, what: &'static str) -> WireResult<Reader<'a>> {
|
||||
Ok(Reader::new(self.bytes(n, what)?))
|
||||
}
|
||||
|
||||
/// A varint length, at most `limit`, then that many bytes.
|
||||
pub fn length_prefixed(&mut self, limit: Option<u64>, what: &'static str) -> WireResult<&'a [u8]> {
|
||||
let n = self.varint(what)?;
|
||||
if let Some(limit) = limit.filter(|l| n > *l) {
|
||||
return Err(WireError::LengthExceedsLimit { what, len: n, limit });
|
||||
}
|
||||
self.bytes(n, what)
|
||||
}
|
||||
|
||||
/// Refuse anything left over.
|
||||
pub fn finish(&self, what: &'static str) -> WireResult<()> {
|
||||
if self.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WireError::TrailingBytes { what, count: self.remaining() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a varint (shortest form).
|
||||
pub fn put_varint(out: &mut Vec<u8>, v: u64) {
|
||||
varint::put(out, v)
|
||||
}
|
||||
|
||||
/// Append a varint length, then the bytes; refused past `limit`.
|
||||
pub fn put_length_prefixed(out: &mut Vec<u8>, b: &[u8], limit: Option<u64>, what: &'static str) -> WireResult<()> {
|
||||
if let Some(limit) = limit.filter(|l| b.len() as u64 > *l) {
|
||||
return Err(WireError::LengthExceedsLimit { what, len: b.len() as u64, limit });
|
||||
}
|
||||
put_varint(out, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_are_bounded() {
|
||||
let mut r = Reader::new(&[0x02, 0xaa]);
|
||||
assert_eq!(r.length_prefixed(None, "x"), Err(WireError::Truncated { what: "x" }));
|
||||
let mut r = Reader::new(&[0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaa]);
|
||||
// A length of 2^64-1 with one byte behind it allocates nothing and refuses.
|
||||
assert_eq!(r.length_prefixed(None, "x"), Err(WireError::Truncated { what: "x" }));
|
||||
let mut r = Reader::new(&[0x03, 1, 2, 3]);
|
||||
assert_eq!(r.length_prefixed(Some(2), "x"), Err(WireError::LengthExceedsLimit { what: "x", len: 3, limit: 2 }));
|
||||
let mut r = Reader::new(&[0x01, 0x07, 0x09]);
|
||||
assert_eq!(r.length_prefixed(None, "x").unwrap(), &[7]);
|
||||
assert_eq!(r.finish("x"), Err(WireError::TrailingBytes { what: "x", count: 1 }));
|
||||
}
|
||||
}
|
||||
330
joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs
Normal file
330
joule-code/joule-code-rs/crates/joule-moq/src/draft22/json.rs
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
//! A JSON view of decoded draft-22 values.
|
||||
//!
|
||||
//! The vector corpus under `tests/vectors/draft-22/` states what each vector
|
||||
//! means in this form, and its independent checker builds the same form from
|
||||
//! the bytes; the README of this crate describes it. Byte strings are lowercase
|
||||
//! hex; a Track Namespace is a list of its fields in hex; a Location is
|
||||
//! `[group, object]`; integers are JSON numbers (every one fits 64 bits).
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::control::{msg, ControlMessage, NamespaceRequest, TrackRequest};
|
||||
use super::data::{Datagram, DatagramContent, FetchEntry, RangeEnd, StreamHeader, SubgroupId, UniEvent};
|
||||
use super::error::{Consequence, WireError};
|
||||
use super::kvp::{KeyValuePair, KvValue, Properties};
|
||||
use super::names::Location;
|
||||
use super::params::{param, LocationFilter, Parameter, Parameters};
|
||||
use super::token::Token;
|
||||
use crate::track::TrackNamespace;
|
||||
|
||||
pub fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn ns(n: &TrackNamespace) -> Value {
|
||||
Value::Array(n.0.iter().map(|f| Value::String(hex(f))).collect())
|
||||
}
|
||||
|
||||
fn loc(l: &Location) -> Value {
|
||||
json!([l.group, l.object])
|
||||
}
|
||||
|
||||
pub fn pairs(ps: &[KeyValuePair]) -> Value {
|
||||
Value::Array(
|
||||
ps.iter()
|
||||
.map(|p| match &p.value {
|
||||
KvValue::Varint(v) => json!({"type": p.ty, "value": v}),
|
||||
KvValue::Bytes(b) => json!({"type": p.ty, "bytes": hex(b)}),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn properties(p: &Properties) -> Value {
|
||||
pairs(&p.0)
|
||||
}
|
||||
|
||||
fn token(t: &Token) -> Value {
|
||||
match t {
|
||||
Token::Delete { alias } => json!({"alias_type": 0, "alias": alias}),
|
||||
Token::Register { alias, token_type, value } => json!({"alias_type": 1, "alias": alias, "token_type": token_type, "value": hex(value)}),
|
||||
Token::UseAlias { alias } => json!({"alias_type": 2, "alias": alias}),
|
||||
Token::UseValue { token_type, value } => json!({"alias_type": 3, "token_type": token_type, "value": hex(value)}),
|
||||
}
|
||||
}
|
||||
|
||||
fn location_filter(f: &LocationFilter) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert("filter_type".into(), json!(f.type_code()));
|
||||
let start = |l: &Location, m: &mut Map<String, Value>| {
|
||||
m.insert("start_group".into(), json!(l.group));
|
||||
m.insert("start_object".into(), json!(l.object));
|
||||
};
|
||||
match f {
|
||||
LocationFilter::None | LocationFilter::NextObject => {}
|
||||
LocationFilter::RelativeStart { start_group } => {
|
||||
m.insert("start_group".into(), json!(start_group));
|
||||
}
|
||||
LocationFilter::AbsoluteStart { start: s } => start(s, &mut m),
|
||||
LocationFilter::AbsoluteStartGroupEnd { start: s, end_group_delta } => {
|
||||
start(s, &mut m);
|
||||
m.insert("end_group_delta".into(), json!(end_group_delta));
|
||||
}
|
||||
LocationFilter::AbsoluteRange { start: s, end_group_delta, end_object } => {
|
||||
start(s, &mut m);
|
||||
m.insert("end_group_delta".into(), json!(end_group_delta));
|
||||
m.insert("end_object".into(), json!(end_object));
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn parameter(p: &Parameter) -> Value {
|
||||
let name = param::name(p.type_code()).unwrap_or("?");
|
||||
let mut m = Map::new();
|
||||
m.insert("type".into(), json!(name));
|
||||
match p {
|
||||
Parameter::ObjectDeliveryTimeout(v)
|
||||
| Parameter::RendezvousTimeout(v)
|
||||
| Parameter::SubgroupDeliveryTimeout(v)
|
||||
| Parameter::Expires(v)
|
||||
| Parameter::FillTimeout(v)
|
||||
| Parameter::NewGroupRequest(v) => {
|
||||
m.insert("value".into(), json!(v));
|
||||
}
|
||||
Parameter::AuthorizationToken(t) => {
|
||||
m.insert("token".into(), token(t));
|
||||
}
|
||||
Parameter::LargestObject(l) => {
|
||||
m.insert("location".into(), loc(l));
|
||||
}
|
||||
Parameter::Forward(b) | Parameter::IncludeProperties(b) => {
|
||||
m.insert("value".into(), json!(*b as u8));
|
||||
}
|
||||
Parameter::SubscriberPriority(v) => {
|
||||
m.insert("value".into(), json!(v));
|
||||
}
|
||||
Parameter::GroupOrder(g) => {
|
||||
m.insert("value".into(), json!(*g as u8));
|
||||
}
|
||||
Parameter::LocationFilter(f) => {
|
||||
m.insert("filter".into(), location_filter(f));
|
||||
}
|
||||
Parameter::FillParameters(ps) => {
|
||||
m.insert("parameters".into(), parameters(ps));
|
||||
}
|
||||
Parameter::RangeFilter { filter, .. } => {
|
||||
let f = match filter {
|
||||
None => Value::Null,
|
||||
Some(f) => {
|
||||
let mut fm = Map::new();
|
||||
fm.insert("set_id".into(), json!(f.set_id));
|
||||
if let Some(t) = f.property_type {
|
||||
fm.insert("property_type".into(), json!(t));
|
||||
}
|
||||
fm.insert("ranges".into(), Value::Array(f.ranges.iter().map(|r| json!([r.start, r.end])).collect()));
|
||||
Value::Object(fm)
|
||||
}
|
||||
};
|
||||
m.insert("filter".into(), f);
|
||||
}
|
||||
Parameter::TrackNamespacePrefix(n) => {
|
||||
m.insert("namespace".into(), ns(n));
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn parameters(ps: &Parameters) -> Value {
|
||||
Value::Array(ps.0.iter().map(parameter).collect())
|
||||
}
|
||||
|
||||
fn track_request(r: &TrackRequest) -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
m.insert("request_id".into(), json!(r.request_id));
|
||||
m.insert("namespace".into(), ns(&r.namespace));
|
||||
m.insert("name".into(), json!(hex(&r.name.0)));
|
||||
m.insert("parameters".into(), parameters(&r.params));
|
||||
m
|
||||
}
|
||||
|
||||
fn ns_request(r: &NamespaceRequest) -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
m.insert("request_id".into(), json!(r.request_id));
|
||||
m.insert("prefix".into(), ns(&r.prefix));
|
||||
m.insert("parameters".into(), parameters(&r.params));
|
||||
m
|
||||
}
|
||||
|
||||
pub fn control(c: &ControlMessage) -> Value {
|
||||
let mut m = match c {
|
||||
ControlMessage::Setup(s) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("options".into(), pairs(&s.options.0));
|
||||
m
|
||||
}
|
||||
ControlMessage::Goaway(g) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("new_session_uri".into(), json!(hex(&g.new_session_uri)));
|
||||
m.insert("timeout_ms".into(), json!(g.timeout_ms));
|
||||
m
|
||||
}
|
||||
ControlMessage::Subscribe(r) | ControlMessage::Fetch(r) | ControlMessage::TrackStatus(r) => track_request(r),
|
||||
ControlMessage::SubscribeOk(s) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("track_alias".into(), json!(s.track_alias));
|
||||
m.insert("parameters".into(), parameters(&s.params));
|
||||
m.insert("track_properties".into(), properties(&s.track_properties));
|
||||
m
|
||||
}
|
||||
ControlMessage::PublishStateNotify(p) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("parameters".into(), parameters(p));
|
||||
m
|
||||
}
|
||||
ControlMessage::Publish(p) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("request_id".into(), json!(p.request_id));
|
||||
m.insert("namespace".into(), ns(&p.namespace));
|
||||
m.insert("name".into(), json!(hex(&p.name.0)));
|
||||
m.insert("track_alias".into(), json!(p.track_alias));
|
||||
m.insert("parameters".into(), parameters(&p.params));
|
||||
m.insert("track_properties".into(), properties(&p.track_properties));
|
||||
m
|
||||
}
|
||||
ControlMessage::PublishDone(d) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("status".into(), json!(d.status.0));
|
||||
m.insert("stream_count".into(), json!(d.stream_count));
|
||||
m.insert("reason".into(), json!(hex(&d.reason)));
|
||||
m
|
||||
}
|
||||
ControlMessage::FetchOk(f) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("end_of_track".into(), json!(f.end_of_track as u8));
|
||||
m.insert("end_location".into(), loc(&f.end_location));
|
||||
m.insert("parameters".into(), parameters(&f.params));
|
||||
m.insert("track_properties".into(), properties(&f.track_properties));
|
||||
m
|
||||
}
|
||||
ControlMessage::PublishNamespace(r) | ControlMessage::SubscribeNamespace(r) | ControlMessage::SubscribeTracks(r) => ns_request(r),
|
||||
ControlMessage::Namespace(n) | ControlMessage::NamespaceDone(n) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("suffix".into(), ns(n));
|
||||
m
|
||||
}
|
||||
ControlMessage::PublishSkipped(s) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("suffix".into(), ns(&s.suffix));
|
||||
m.insert("name".into(), json!(hex(&s.name.0)));
|
||||
m
|
||||
}
|
||||
ControlMessage::RequestUpdate(u) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("request_id".into(), json!(u.request_id));
|
||||
m.insert("parameters".into(), parameters(&u.params));
|
||||
m
|
||||
}
|
||||
ControlMessage::RequestOk(o) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("parameters".into(), parameters(&o.params));
|
||||
m.insert("track_properties".into(), properties(&o.track_properties));
|
||||
m
|
||||
}
|
||||
ControlMessage::RequestError(e) => {
|
||||
let mut m = Map::new();
|
||||
m.insert("code".into(), json!(e.code.0));
|
||||
m.insert("retry_interval".into(), json!(e.retry_interval));
|
||||
m.insert("reason".into(), json!(hex(&e.reason)));
|
||||
m.insert(
|
||||
"redirect".into(),
|
||||
match &e.redirect {
|
||||
None => Value::Null,
|
||||
Some(r) => json!({"connect_uri": hex(&r.connect_uri), "namespace": ns(&r.namespace), "name": hex(&r.name.0)}),
|
||||
},
|
||||
);
|
||||
m
|
||||
}
|
||||
};
|
||||
m.insert("message".into(), json!(msg::name(c.type_code()).unwrap_or("?")));
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
pub fn uni_event(e: &UniEvent) -> Value {
|
||||
match e {
|
||||
UniEvent::Header(StreamHeader::Control) => json!({"header": "control"}),
|
||||
UniEvent::Header(StreamHeader::Padding) => json!({"header": "padding"}),
|
||||
UniEvent::Header(StreamHeader::Fetch(f)) => json!({"header": "fetch", "request_id": f.request_id}),
|
||||
UniEvent::Header(StreamHeader::Subgroup(h)) => {
|
||||
let (mode, sid) = match h.subgroup_id {
|
||||
SubgroupId::Zero => (0, Value::Null),
|
||||
SubgroupId::FirstObjectId => (1, Value::Null),
|
||||
SubgroupId::Explicit(s) => (2, json!(s)),
|
||||
};
|
||||
json!({
|
||||
"header": "subgroup",
|
||||
"track_alias": h.track_alias,
|
||||
"group_id": h.group_id,
|
||||
"subgroup_id_mode": mode,
|
||||
"subgroup_id": sid,
|
||||
"publisher_priority": h.publisher_priority,
|
||||
"properties": h.properties,
|
||||
"end_of_group": h.end_of_group,
|
||||
"first_object": h.first_object,
|
||||
})
|
||||
}
|
||||
UniEvent::Control(c) => json!({"control": control(c)}),
|
||||
UniEvent::SubgroupObject(o) => json!({"object": {
|
||||
"object_id": o.object_id,
|
||||
"properties": properties(&o.properties),
|
||||
"status": o.status.code(),
|
||||
"payload": hex(&o.payload),
|
||||
}}),
|
||||
UniEvent::FetchEntry(FetchEntry::Object(o)) => json!({"fetch_object": {
|
||||
"group_id": o.group_id,
|
||||
"object_id": o.object_id,
|
||||
"subgroup_id": o.subgroup_id,
|
||||
"publisher_priority": o.publisher_priority,
|
||||
"properties": o.properties.as_ref().map(properties),
|
||||
"payload": hex(&o.payload),
|
||||
}}),
|
||||
UniEvent::FetchEntry(FetchEntry::EndOfRange { kind, location }) => json!({"end_of_range": {
|
||||
"kind": match kind { RangeEnd::NonExistent => "non-existent", RangeEnd::Unknown => "unknown", RangeEnd::TimedOut => "timed-out" },
|
||||
"location": loc(location),
|
||||
}}),
|
||||
UniEvent::Padding(n) => json!({"padding": n}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn datagram(d: &Datagram) -> Value {
|
||||
match d {
|
||||
Datagram::Padding(n) => json!({"padding": n}),
|
||||
Datagram::Object(o) => {
|
||||
let (status, payload) = match &o.content {
|
||||
DatagramContent::Status(s) => (json!(s.code()), Value::Null),
|
||||
DatagramContent::Payload(p) => (Value::Null, json!(hex(p))),
|
||||
};
|
||||
json!({"datagram": {
|
||||
"track_alias": o.track_alias,
|
||||
"group_id": o.group_id,
|
||||
"object_id": o.object_id,
|
||||
"publisher_priority": o.publisher_priority,
|
||||
"properties": o.properties.as_ref().map(properties),
|
||||
"end_of_group": o.end_of_group,
|
||||
"status": status,
|
||||
"payload": payload,
|
||||
}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal: its kind and what the draft says follows.
|
||||
pub fn error(e: &WireError) -> Value {
|
||||
let consequence = match e.consequence() {
|
||||
Consequence::CloseSession(c) => format!("session:{c}"),
|
||||
Consequence::RejectRequest(c) => format!("request:{c}"),
|
||||
Consequence::MalformedTrack => "malformed-track".into(),
|
||||
Consequence::ResetStream(c) => format!("reset:{c}"),
|
||||
};
|
||||
json!({"error": e.kind(), "consequence": consequence})
|
||||
}
|
||||
405
joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs
Normal file
405
joule-code/joule-code-rs/crates/joule-moq/src/draft22/kvp.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
//! Key-Value-Pairs (§8.3): Track and Object Properties (§8.4, §10, §11.1.2)
|
||||
//! and Setup Options (§9.1).
|
||||
//!
|
||||
//! A pair's type is delta-encoded from the previous pair's, so a list is in
|
||||
//! non-decreasing type order on the wire; an even type carries one varint, an
|
||||
//! odd type a length of at most 2^16-1 and that many bytes.
|
||||
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::io::{put_varint, Reader};
|
||||
use super::token::Token;
|
||||
|
||||
/// The largest value of an odd-typed pair (§8.3).
|
||||
pub const MAX_KVP_VALUE_LEN: u64 = 0xffff;
|
||||
|
||||
/// A pair's value: a varint for an even type, bytes for an odd one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum KvValue {
|
||||
Varint(u64),
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
/// One Key-Value-Pair.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct KeyValuePair {
|
||||
pub ty: u64,
|
||||
pub value: KvValue,
|
||||
}
|
||||
|
||||
impl KeyValuePair {
|
||||
pub fn varint(ty: u64, v: u64) -> Self {
|
||||
KeyValuePair { ty, value: KvValue::Varint(v) }
|
||||
}
|
||||
pub fn bytes(ty: u64, b: impl Into<Vec<u8>>) -> Self {
|
||||
KeyValuePair { ty, value: KvValue::Bytes(b.into()) }
|
||||
}
|
||||
pub fn as_varint(&self) -> Option<u64> {
|
||||
match self.value {
|
||||
KvValue::Varint(v) => Some(v),
|
||||
KvValue::Bytes(_) => None,
|
||||
}
|
||||
}
|
||||
pub fn as_bytes(&self) -> Option<&[u8]> {
|
||||
match &self.value {
|
||||
KvValue::Bytes(b) => Some(b),
|
||||
KvValue::Varint(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `pairs` in non-decreasing type order (a stable sort: pairs of one
|
||||
/// type keep their order). Refused where a value's form does not match its
|
||||
/// type's parity or a value is longer than 2^16-1.
|
||||
pub fn encode_pairs(pairs: &[KeyValuePair], out: &mut Vec<u8>) -> WireResult<()> {
|
||||
let mut sorted: Vec<&KeyValuePair> = pairs.iter().collect();
|
||||
sorted.sort_by_key(|p| p.ty);
|
||||
let mut prev = 0u64;
|
||||
for p in sorted {
|
||||
put_varint(out, p.ty - prev);
|
||||
prev = p.ty;
|
||||
match (&p.value, p.ty % 2) {
|
||||
(KvValue::Varint(v), 0) => put_varint(out, *v),
|
||||
(KvValue::Bytes(b), 1) => {
|
||||
if b.len() as u64 > MAX_KVP_VALUE_LEN {
|
||||
return Err(WireError::LengthExceedsLimit { what: "key-value pair", len: b.len() as u64, limit: MAX_KVP_VALUE_LEN });
|
||||
}
|
||||
put_varint(out, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
_ => return Err(WireError::Unencodable { what: "key-value pair", reason: "an even type carries a varint, an odd type bytes" }),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode pairs until `r` is empty.
|
||||
pub fn decode_pairs(r: &mut Reader<'_>, what: &'static str) -> WireResult<Vec<KeyValuePair>> {
|
||||
let mut out = Vec::new();
|
||||
let mut prev = 0u64;
|
||||
while !r.is_empty() {
|
||||
let delta = r.varint(what)?;
|
||||
let ty = prev.checked_add(delta).ok_or(WireError::TypeOverflow { what })?;
|
||||
prev = ty;
|
||||
let value = if ty % 2 == 0 {
|
||||
KvValue::Varint(r.varint(what)?)
|
||||
} else {
|
||||
KvValue::Bytes(r.length_prefixed(Some(MAX_KVP_VALUE_LEN), what)?.to_vec())
|
||||
};
|
||||
out.push(KeyValuePair { ty, value });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ── Properties (§8.4, §10, §16.8) ───────────────────────────────────────────
|
||||
|
||||
/// Property types this draft defines (§10, Table 15).
|
||||
pub mod prop {
|
||||
pub const OBJECT_DELIVERY_TIMEOUT: u64 = 0x02;
|
||||
pub const MAX_CACHE_DURATION: u64 = 0x04;
|
||||
pub const SUBGROUP_DELIVERY_TIMEOUT: u64 = 0x06;
|
||||
pub const IMMUTABLE_PROPERTIES: u64 = 0x0b;
|
||||
pub const DEFAULT_PUBLISHER_PRIORITY: u64 = 0x0e;
|
||||
pub const DEFAULT_PUBLISHER_GROUP_ORDER: u64 = 0x22;
|
||||
pub const DYNAMIC_GROUPS: u64 = 0x30;
|
||||
pub const PRIOR_GROUP_ID_GAP: u64 = 0x3c;
|
||||
pub const PRIOR_OBJECT_ID_GAP: u64 = 0x3e;
|
||||
/// Mandatory Track Properties (§3.7).
|
||||
pub const MANDATORY_TRACK: std::ops::RangeInclusive<u64> = 0x4000..=0x7fff;
|
||||
/// Ranges an application uses without registration (§8.4).
|
||||
pub const APPLICATION_1BYTE: std::ops::RangeInclusive<u64> = 0x78..=0x7f;
|
||||
pub const APPLICATION_2BYTE: std::ops::RangeInclusive<u64> = 0x3800..=0x3fff;
|
||||
}
|
||||
|
||||
/// A list of Track or Object Properties: the mutable list as it is on the wire.
|
||||
/// An Immutable Properties pair (type 0x0B) holds a list of its own;
|
||||
/// [`Properties::immutable`] reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Properties(pub Vec<KeyValuePair>);
|
||||
|
||||
impl Properties {
|
||||
pub fn new() -> Self {
|
||||
Properties(Vec::new())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Add a pair (order on the wire is by type; see [`encode_pairs`]).
|
||||
pub fn push(&mut self, p: KeyValuePair) -> &mut Self {
|
||||
self.0.push(p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the Immutable Properties pair to `inner`, replacing one already present.
|
||||
pub fn set_immutable(&mut self, inner: &Properties) -> WireResult<&mut Self> {
|
||||
let mut b = Vec::new();
|
||||
encode_pairs(&inner.0, &mut b)?;
|
||||
self.0.retain(|p| p.ty != prop::IMMUTABLE_PROPERTIES);
|
||||
self.0.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
encode_pairs(&self.0, out)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> WireResult<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
self.encode(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a whole byte string as a property list.
|
||||
pub fn decode(b: &[u8], what: &'static str) -> WireResult<Self> {
|
||||
Ok(Properties(decode_pairs(&mut Reader::new(b), what)?))
|
||||
}
|
||||
|
||||
/// The contents of the Immutable Properties pair: `None` when there is none,
|
||||
/// and an error when they do not parse (§10.7: a malformed track).
|
||||
pub fn immutable(&self) -> WireResult<Option<Properties>> {
|
||||
match self.0.iter().find(|p| p.ty == prop::IMMUTABLE_PROPERTIES).and_then(KeyValuePair::as_bytes) {
|
||||
None => Ok(None),
|
||||
Some(b) => Properties::decode(b, "immutable properties")
|
||||
.map(Some)
|
||||
.map_err(|_| WireError::MalformedTrack { reason: "immutable properties do not parse" }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every pair of type `ty`, from the mutable list and the immutable one
|
||||
/// (§10.7: "processors MUST search both").
|
||||
pub fn find_all(&self, ty: u64) -> WireResult<Vec<KeyValuePair>> {
|
||||
let mut out: Vec<KeyValuePair> = self.0.iter().filter(|p| p.ty == ty).cloned().collect();
|
||||
if let Some(inner) = self.immutable()? {
|
||||
out.extend(inner.0.into_iter().filter(|p| p.ty == ty));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The first pair of type `ty`, mutable list first.
|
||||
pub fn find(&self, ty: u64) -> WireResult<Option<KeyValuePair>> {
|
||||
Ok(self.find_all(ty)?.into_iter().next())
|
||||
}
|
||||
|
||||
/// Both lists, the mutable one first, without the Immutable Properties pair.
|
||||
pub fn flattened(&self) -> WireResult<Vec<KeyValuePair>> {
|
||||
let mut out: Vec<KeyValuePair> = self.0.iter().filter(|p| p.ty != prop::IMMUTABLE_PROPERTIES).cloned().collect();
|
||||
if let Some(inner) = self.immutable()? {
|
||||
out.extend(inner.0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The checks an Object's properties must pass on their own (§3.7,
|
||||
/// §10.7-10.9), given the object's Group ID and Object ID. Each failure is a
|
||||
/// malformed track. In order: one Immutable Properties pair at most; its
|
||||
/// contents parse and hold no Immutable Properties; no Mandatory Track
|
||||
/// Property; one Prior Group ID Gap and one Prior Object ID Gap at most, each
|
||||
/// no larger than the ID it precedes.
|
||||
pub fn check_object(&self, group_id: u64, object_id: u64) -> WireResult<()> {
|
||||
let mt = |reason| Err(WireError::MalformedTrack { reason });
|
||||
if self.0.iter().filter(|p| p.ty == prop::IMMUTABLE_PROPERTIES).count() > 1 {
|
||||
return mt("more than one Immutable Properties");
|
||||
}
|
||||
if self.immutable()?.is_some_and(|inner| inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES)) {
|
||||
return mt("Immutable Properties inside Immutable Properties");
|
||||
}
|
||||
let all = self.flattened()?;
|
||||
if all.iter().any(|p| prop::MANDATORY_TRACK.contains(&p.ty)) {
|
||||
return mt("a Mandatory Track Property on an object");
|
||||
}
|
||||
for (ty, id, twice, larger) in [
|
||||
(prop::PRIOR_GROUP_ID_GAP, group_id, "more than one Prior Group ID Gap", "a Prior Group ID Gap larger than the Group ID"),
|
||||
(prop::PRIOR_OBJECT_ID_GAP, object_id, "more than one Prior Object ID Gap", "a Prior Object ID Gap larger than the Object ID"),
|
||||
] {
|
||||
let gaps: Vec<u64> = all.iter().filter(|p| p.ty == ty).filter_map(KeyValuePair::as_varint).collect();
|
||||
if gaps.len() > 1 {
|
||||
return mt(twice);
|
||||
}
|
||||
if gaps.first().is_some_and(|g| *g > id) {
|
||||
return mt(larger);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The checks Track Properties must pass (§10.4-10.7): their Immutable
|
||||
/// Properties parse and nest none; DEFAULT_PUBLISHER_PRIORITY at most 255;
|
||||
/// DEFAULT_PUBLISHER_GROUP_ORDER 1 or 2; DYNAMIC_GROUPS 0 or 1.
|
||||
pub fn check_track(&self) -> WireResult<()> {
|
||||
if self.immutable()?.is_some_and(|inner| inner.0.iter().any(|p| p.ty == prop::IMMUTABLE_PROPERTIES)) {
|
||||
return Err(WireError::MalformedTrack { reason: "Immutable Properties inside Immutable Properties" });
|
||||
}
|
||||
for p in self.flattened()? {
|
||||
let v = p.as_varint();
|
||||
match (p.ty, v) {
|
||||
(prop::DEFAULT_PUBLISHER_PRIORITY, Some(v)) if v > 255 => {
|
||||
return Err(WireError::InvalidValue { what: "DEFAULT_PUBLISHER_PRIORITY", value: v })
|
||||
}
|
||||
(prop::DEFAULT_PUBLISHER_GROUP_ORDER, Some(v)) if v != 1 && v != 2 => {
|
||||
return Err(WireError::InvalidValue { what: "DEFAULT_PUBLISHER_GROUP_ORDER", value: v })
|
||||
}
|
||||
(prop::DYNAMIC_GROUPS, Some(v)) if v > 1 => return Err(WireError::InvalidValue { what: "DYNAMIC_GROUPS", value: v }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first Mandatory Track Property (§3.7) whose type is not in `known`:
|
||||
/// a track carrying one MUST NOT be processed or forwarded by this endpoint.
|
||||
pub fn unknown_mandatory(&self, known: &[u64]) -> WireResult<Option<u64>> {
|
||||
Ok(self.flattened()?.iter().map(|p| p.ty).find(|t| prop::MANDATORY_TRACK.contains(t) && !known.contains(t)))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup Options (§9.1, §16.4) ─────────────────────────────────────────────
|
||||
|
||||
/// Setup Option types this draft defines (Table 11).
|
||||
pub mod opt {
|
||||
pub const PATH: u64 = 0x01;
|
||||
pub const AUTHORIZATION_TOKEN: u64 = 0x03;
|
||||
pub const MAX_AUTH_TOKEN_CACHE_SIZE: u64 = 0x04;
|
||||
pub const AUTHORITY: u64 = 0x05;
|
||||
pub const MAX_FILTER_RANGES: u64 = 0x06;
|
||||
pub const MOQT_IMPLEMENTATION: u64 = 0x07;
|
||||
pub const MAX_REQUEST_UPDATES: u64 = 0x08;
|
||||
/// Every option this draft defines.
|
||||
pub const KNOWN: [u64; 7] = [PATH, AUTHORIZATION_TOKEN, MAX_AUTH_TOKEN_CACHE_SIZE, AUTHORITY, MAX_FILTER_RANGES, MOQT_IMPLEMENTATION, MAX_REQUEST_UPDATES];
|
||||
}
|
||||
|
||||
/// The Setup Options of a SETUP message. Unknown options are kept (and, per
|
||||
/// §9.1, ignored by every accessor here); a known option other than
|
||||
/// AUTHORIZATION_TOKEN appears at most once.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SetupOptions(pub Vec<KeyValuePair>);
|
||||
|
||||
impl SetupOptions {
|
||||
pub fn new() -> Self {
|
||||
SetupOptions(Vec::new())
|
||||
}
|
||||
|
||||
pub fn push(&mut self, p: KeyValuePair) -> &mut Self {
|
||||
self.0.push(p);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
self.check()?;
|
||||
encode_pairs(&self.0, out)
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
let s = SetupOptions(decode_pairs(r, "setup options")?);
|
||||
s.check()?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// No known option repeated except AUTHORIZATION_TOKEN (§9.1, §9.1.4), and
|
||||
/// every AUTHORIZATION_TOKEN a Token structure (§8.9).
|
||||
fn check(&self) -> WireResult<()> {
|
||||
for k in opt::KNOWN {
|
||||
if k != opt::AUTHORIZATION_TOKEN && self.0.iter().filter(|p| p.ty == k).count() > 1 {
|
||||
return Err(WireError::Duplicate { what: "setup option", ty: k });
|
||||
}
|
||||
}
|
||||
for p in self.0.iter().filter(|p| p.ty == opt::AUTHORIZATION_TOKEN) {
|
||||
Token::decode(p.as_bytes().unwrap_or_default())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bytes_of(&self, ty: u64) -> Option<&[u8]> {
|
||||
self.0.iter().find(|p| p.ty == ty).and_then(KeyValuePair::as_bytes)
|
||||
}
|
||||
|
||||
fn varint_of(&self, ty: u64) -> Option<u64> {
|
||||
self.0.iter().find(|p| p.ty == ty).and_then(KeyValuePair::as_varint)
|
||||
}
|
||||
|
||||
/// PATH (§9.1.2), as sent.
|
||||
pub fn path(&self) -> Option<&[u8]> {
|
||||
self.bytes_of(opt::PATH)
|
||||
}
|
||||
/// AUTHORITY (§9.1.1), as sent.
|
||||
pub fn authority(&self) -> Option<&[u8]> {
|
||||
self.bytes_of(opt::AUTHORITY)
|
||||
}
|
||||
/// MOQT_IMPLEMENTATION (§9.1.5), as sent.
|
||||
pub fn implementation(&self) -> Option<&[u8]> {
|
||||
self.bytes_of(opt::MOQT_IMPLEMENTATION)
|
||||
}
|
||||
/// MAX_AUTH_TOKEN_CACHE_SIZE (§9.1.3); 0 when absent.
|
||||
pub fn max_auth_token_cache_size(&self) -> u64 {
|
||||
self.varint_of(opt::MAX_AUTH_TOKEN_CACHE_SIZE).unwrap_or(0)
|
||||
}
|
||||
/// MAX_FILTER_RANGES (§9.1.6); 0 when absent, which forbids Range Filters.
|
||||
pub fn max_filter_ranges(&self) -> u64 {
|
||||
self.varint_of(opt::MAX_FILTER_RANGES).unwrap_or(0)
|
||||
}
|
||||
/// MAX_REQUEST_UPDATES (§9.1.7); 0 when absent, which is no limit.
|
||||
pub fn max_request_updates(&self) -> u64 {
|
||||
self.varint_of(opt::MAX_REQUEST_UPDATES).unwrap_or(0)
|
||||
}
|
||||
/// Every AUTHORIZATION_TOKEN (§9.1.4).
|
||||
pub fn authorization_tokens(&self) -> Vec<Token> {
|
||||
self.0
|
||||
.iter()
|
||||
.filter(|p| p.ty == opt::AUTHORIZATION_TOKEN)
|
||||
.filter_map(|p| Token::decode(p.as_bytes()?).ok())
|
||||
.collect()
|
||||
}
|
||||
/// The value of an option this draft does not define, for an extension
|
||||
/// that declares itself by one (§6.3.2).
|
||||
pub fn extension(&self, ty: u64) -> Option<&KeyValuePair> {
|
||||
self.0.iter().find(|p| p.ty == ty)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pairs_are_delta_coded_in_type_order() {
|
||||
let ps = vec![KeyValuePair::bytes(0x3a41, b"ab".to_vec()), KeyValuePair::varint(0x02, 300), KeyValuePair::varint(0x02, 5)];
|
||||
let mut b = Vec::new();
|
||||
encode_pairs(&ps, &mut b).unwrap();
|
||||
// 0x02 (delta 2) 300, delta 0 5, delta 0x3a3f then length 2.
|
||||
let back = decode_pairs(&mut Reader::new(&b), "t").unwrap();
|
||||
assert_eq!(back, vec![ps[1].clone(), ps[2].clone(), ps[0].clone()]);
|
||||
assert_eq!(b[0], 0x02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drafts_limits_hold() {
|
||||
// An odd type whose length is 2^16.
|
||||
let mut b = vec![0x01];
|
||||
put_varint(&mut b, 0x1_0000);
|
||||
assert!(matches!(decode_pairs(&mut Reader::new(&b), "t"), Err(WireError::LengthExceedsLimit { .. })));
|
||||
// A delta past 2^64-1.
|
||||
let mut b = Vec::new();
|
||||
put_varint(&mut b, u64::MAX);
|
||||
put_varint(&mut b, 0);
|
||||
put_varint(&mut b, 1);
|
||||
assert_eq!(decode_pairs(&mut Reader::new(&b), "t"), Err(WireError::TypeOverflow { what: "t" }));
|
||||
assert!(encode_pairs(&[KeyValuePair::varint(1, 0)], &mut Vec::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_checks() {
|
||||
let mut inner = Properties::new();
|
||||
inner.push(KeyValuePair::varint(prop::PRIOR_OBJECT_ID_GAP, 2));
|
||||
let mut p = Properties::new();
|
||||
p.set_immutable(&inner).unwrap();
|
||||
assert!(p.check_object(0, 2).is_ok());
|
||||
assert!(matches!(p.check_object(0, 1), Err(WireError::MalformedTrack { .. })));
|
||||
p.push(KeyValuePair::varint(prop::PRIOR_OBJECT_ID_GAP, 1));
|
||||
assert!(matches!(p.check_object(0, 9), Err(WireError::MalformedTrack { .. })), "one in each list is two");
|
||||
let mut q = Properties::new();
|
||||
q.push(KeyValuePair::varint(0x4000, 1));
|
||||
assert!(matches!(q.check_object(0, 0), Err(WireError::MalformedTrack { .. })));
|
||||
assert_eq!(q.unknown_mandatory(&[]).unwrap(), Some(0x4000));
|
||||
assert_eq!(q.unknown_mandatory(&[0x4000]).unwrap(), None);
|
||||
}
|
||||
}
|
||||
70
joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs
Normal file
70
joule-code/joule-code-rs/crates/joule-moq/src/draft22/mod.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! The wire codec of `draft-ietf-moq-transport-22` (1 October 2026).
|
||||
//!
|
||||
//! Everything a -22 endpoint puts on the wire or reads from it, and nothing of
|
||||
//! another draft: deployed relays speak -14, -16 and -18, and no two drafts
|
||||
//! interoperate (the varint alone changed in -17). A later draft, or an earlier
|
||||
//! one for interoperation, is a sibling module beside this one, chosen by
|
||||
//! [`crate::draft::Draft`]; the draft-neutral data model (tracks, objects,
|
||||
//! receipts) stays where it is.
|
||||
//!
|
||||
//! | -22 | here |
|
||||
//! |---|---|
|
||||
//! | §8.1 variable-length integer | [`varint`] |
|
||||
//! | §8.2 Location, §8.7 Track Namespace, §8.8 rendering | [`names`] |
|
||||
//! | §8.3 Key-Value-Pair, §8.4 / §10 Properties, §9.1 Setup Options | [`kvp`] |
|
||||
//! | §8.9 Authorization Token | [`token`] |
|
||||
//! | §9 control messages | [`control`] |
|
||||
//! | §9.20 Message Parameters, §8.6 Range Filters, §9.20.9 Location Filters | [`params`] |
|
||||
//! | §6.3, §6.4.2 streams and Request IDs, §9.1.7 update credit | [`request`] |
|
||||
//! | §11 subgroup streams, datagrams, fetch streams, padding | [`data`] |
|
||||
//! | §12, §16.11 status and error codes | [`codes`] |
|
||||
//!
|
||||
//! Every decoder is bounded: it reads no byte that is not there, allocates
|
||||
//! nothing from a length before the bytes behind it are known to exist, and
|
||||
//! refuses with a typed [`WireError`] whose [`WireError::consequence`] is the
|
||||
//! draft's (close the session with a code, reject the request, a malformed
|
||||
//! track).
|
||||
//!
|
||||
//! Where -22's text leaves an encoding open this codec takes one reading and
|
||||
//! says so where it applies: FILL_TIMEOUT and RENDEZVOUS_TIMEOUT are varints
|
||||
//! ([`params`]); FILL_PARAMETERS holds parameters up to its length, with no
|
||||
//! count ([`params`]); an End of Range's Group ID and Object ID are the
|
||||
//! delta-coded fields its flags mark ([`data::FetchCodec`]).
|
||||
|
||||
pub mod codes;
|
||||
pub mod control;
|
||||
pub mod data;
|
||||
pub mod error;
|
||||
pub mod io;
|
||||
pub mod json;
|
||||
pub mod kvp;
|
||||
pub mod names;
|
||||
pub mod params;
|
||||
pub mod request;
|
||||
pub mod token;
|
||||
pub mod varint;
|
||||
|
||||
pub use codes::{PublishDoneCode, RequestErrorCode, SessionError, StreamResetCode};
|
||||
pub use control::{
|
||||
ControlMessage, Fetch, FetchOk, Goaway, NamespaceRequest, Publish, PublishDone, PublishSkipped, Redirect, RequestError, RequestOk,
|
||||
RequestUpdate, Setup, Subscribe, SubscribeOk, TrackRequest, TrackStatus,
|
||||
};
|
||||
pub use data::{
|
||||
decode_uni_stream, Datagram, DatagramContent, FetchCodec, FetchEntry, FetchHeader, FetchObject, Limits, ObjectDatagram, ObjectStatus,
|
||||
RangeEnd, StreamHeader, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject, UniEvent, UniStreamDecoder,
|
||||
};
|
||||
pub use error::{Consequence, WireError, WireResult};
|
||||
pub use kvp::{KeyValuePair, KvValue, Properties, SetupOptions};
|
||||
pub use names::Location;
|
||||
pub use params::{GroupOrder, LocationFilter, Parameter, Parameters, Range, RangeFilter};
|
||||
pub use request::{ControlStreamState, PeerRequestIds, Perspective, RequestIds, RequestKind, RequestStream, Side};
|
||||
pub use token::Token;
|
||||
|
||||
/// The ALPN (native QUIC) and WT-Available-Protocols value (WebTransport) of
|
||||
/// this draft (§6.2): "moqt-" and the draft number.
|
||||
pub const ALPN: &[u8] = b"moqt-22";
|
||||
|
||||
/// The conventional 32-bit code of this draft, `0xff000000 + 22`. -22 carries
|
||||
/// no version in SETUP; the draft is the one the ALPN selected, and this code
|
||||
/// only names it.
|
||||
pub const VERSION: u32 = 0xff00_0016;
|
||||
244
joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs
Normal file
244
joule-code/joule-code-rs/crates/joule-moq/src/draft22/names.rs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
//! Track Namespaces and Track Names (§2.4.1, §8.7, §8.8) and Locations (§8.2).
|
||||
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::io::{put_varint, Reader};
|
||||
use crate::track::{TrackName, TrackNamespace};
|
||||
|
||||
/// At most 32 fields in a Track Namespace (§8.7).
|
||||
pub const MAX_NAMESPACE_FIELDS: u64 = 32;
|
||||
/// At most 4,096 bytes in a Full Track Name: the namespace fields' lengths and
|
||||
/// the track name's (§8.7).
|
||||
pub const MAX_FULL_TRACK_NAME: u64 = 4096;
|
||||
|
||||
/// A Location: a Group ID and an Object ID (§8.2), ordered as the draft orders
|
||||
/// them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Location {
|
||||
pub group: u64,
|
||||
pub object: u64,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub const fn new(group: u64, object: u64) -> Self {
|
||||
Location { group, object }
|
||||
}
|
||||
|
||||
pub fn encode(&self, out: &mut Vec<u8>) {
|
||||
put_varint(out, self.group);
|
||||
put_varint(out, self.object);
|
||||
}
|
||||
|
||||
pub fn decode(r: &mut Reader<'_>, what: &'static str) -> WireResult<Self> {
|
||||
Ok(Location { group: r.varint(what)?, object: r.varint(what)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte length of a namespace: the sum of its fields' lengths.
|
||||
pub fn namespace_len(ns: &TrackNamespace) -> u64 {
|
||||
ns.0.iter().map(|f| f.len() as u64).sum()
|
||||
}
|
||||
|
||||
/// The namespace's own checks: at most 32 fields, none empty, at most 4,096
|
||||
/// bytes.
|
||||
pub fn check_namespace(ns: &TrackNamespace) -> WireResult<()> {
|
||||
if ns.0.len() as u64 > MAX_NAMESPACE_FIELDS {
|
||||
return Err(WireError::TooManyNamespaceFields(ns.0.len() as u64));
|
||||
}
|
||||
if ns.0.iter().any(|f| f.is_empty()) {
|
||||
return Err(WireError::EmptyNamespaceField);
|
||||
}
|
||||
let len = namespace_len(ns);
|
||||
if len > MAX_FULL_TRACK_NAME {
|
||||
return Err(WireError::LengthExceedsLimit { what: "track namespace", len, limit: MAX_FULL_TRACK_NAME });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A Full Track Name's checks: the namespace's, and at most 4,096 bytes with
|
||||
/// the name.
|
||||
pub fn check_full_name(ns: &TrackNamespace, name: &TrackName) -> WireResult<()> {
|
||||
check_namespace(ns)?;
|
||||
let len = namespace_len(ns) + name.0.len() as u64;
|
||||
if len > MAX_FULL_TRACK_NAME {
|
||||
return Err(WireError::LengthExceedsLimit { what: "full track name", len, limit: MAX_FULL_TRACK_NAME });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn encode_namespace(ns: &TrackNamespace, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
check_namespace(ns)?;
|
||||
put_varint(out, ns.0.len() as u64);
|
||||
for f in &ns.0 {
|
||||
put_varint(out, f.len() as u64);
|
||||
out.extend_from_slice(f);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a Track Namespace, refusing more than 32 fields before reading any, an
|
||||
/// empty field, and more than 4,096 bytes.
|
||||
pub fn decode_namespace(r: &mut Reader<'_>) -> WireResult<TrackNamespace> {
|
||||
let n = r.varint("track namespace field count")?;
|
||||
if n > MAX_NAMESPACE_FIELDS {
|
||||
return Err(WireError::TooManyNamespaceFields(n));
|
||||
}
|
||||
let mut fields = Vec::with_capacity(n as usize);
|
||||
let mut total = 0u64;
|
||||
for _ in 0..n {
|
||||
let len = r.varint("track namespace field length")?;
|
||||
if len == 0 {
|
||||
return Err(WireError::EmptyNamespaceField);
|
||||
}
|
||||
total = total.saturating_add(len);
|
||||
if total > MAX_FULL_TRACK_NAME {
|
||||
return Err(WireError::LengthExceedsLimit { what: "track namespace", len: total, limit: MAX_FULL_TRACK_NAME });
|
||||
}
|
||||
fields.push(r.bytes(len, "track namespace field")?.to_vec());
|
||||
}
|
||||
Ok(TrackNamespace(fields))
|
||||
}
|
||||
|
||||
pub fn encode_track_name(ns: &TrackNamespace, name: &TrackName, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
check_full_name(ns, name)?;
|
||||
put_varint(out, name.0.len() as u64);
|
||||
out.extend_from_slice(&name.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a Track Name that follows `ns`, refusing a Full Track Name past 4,096
|
||||
/// bytes before reading the name.
|
||||
pub fn decode_track_name(r: &mut Reader<'_>, ns: &TrackNamespace) -> WireResult<TrackName> {
|
||||
let len = r.varint("track name length")?;
|
||||
let full = namespace_len(ns).saturating_add(len);
|
||||
if full > MAX_FULL_TRACK_NAME {
|
||||
return Err(WireError::LengthExceedsLimit { what: "full track name", len: full, limit: MAX_FULL_TRACK_NAME });
|
||||
}
|
||||
Ok(TrackName(r.bytes(len, "track name")?.to_vec()))
|
||||
}
|
||||
|
||||
/// Whether `prefix` matches `ns` (§2.4.2): each of its fields equals the
|
||||
/// field of `ns` at the same position.
|
||||
pub fn prefix_matches(prefix: &TrackNamespace, ns: &TrackNamespace) -> bool {
|
||||
prefix.0.len() <= ns.0.len() && prefix.0.iter().zip(&ns.0).all(|(a, b)| a == b)
|
||||
}
|
||||
|
||||
/// Whether two prefixes share a common prefix in §4.2's sense: one matches the
|
||||
/// other. Such a pair is refused with PREFIX_OVERLAP.
|
||||
pub fn prefixes_overlap(a: &TrackNamespace, b: &TrackNamespace) -> bool {
|
||||
prefix_matches(a, b) || prefix_matches(b, a)
|
||||
}
|
||||
|
||||
/// The namespace `.session` (§6.5) and the reserved first field `.` (§2.4.3).
|
||||
pub const SESSION_NAMESPACE: &[u8] = b".session";
|
||||
|
||||
/// Whether the namespace is reserved (§2.4.3: its first field begins with a
|
||||
/// period).
|
||||
pub fn is_reserved(ns: &TrackNamespace) -> bool {
|
||||
ns.0.first().is_some_and(|f| f.first() == Some(&b'.'))
|
||||
}
|
||||
|
||||
fn render_field(f: &[u8], out: &mut String) {
|
||||
for &b in f {
|
||||
if b.is_ascii_alphanumeric() || b == b'_' {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!(".{b:02x}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §8.8's rendering: fields joined by `-`, then `--` and the track name; bytes
|
||||
/// other than `a-z A-Z 0-9 _` as `.` and two lowercase hex digits.
|
||||
pub fn render(ns: &TrackNamespace, name: Option<&TrackName>) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, f) in ns.0.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push('-');
|
||||
}
|
||||
render_field(f, &mut s);
|
||||
}
|
||||
if let Some(n) = name {
|
||||
s.push_str("--");
|
||||
render_field(&n.0, &mut s);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn parse_field(s: &str) -> Option<Vec<u8>> {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
if c.is_ascii_alphanumeric() || c == b'_' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
} else if c == b'.' && b.len() >= i + 3 {
|
||||
let nib = |x: u8| match x {
|
||||
b'0'..=b'9' => Some(x - b'0'),
|
||||
b'a'..=b'f' => Some(x - b'a' + 10),
|
||||
_ => None,
|
||||
};
|
||||
let v = (nib(b[i + 1])? << 4) | nib(b[i + 2])?;
|
||||
// A byte that has a literal form must use it (§8.8).
|
||||
if v.is_ascii_alphanumeric() || v == b'_' {
|
||||
return None;
|
||||
}
|
||||
out.push(v);
|
||||
i += 3;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse §8.8's rendering, refusing any rendering that is not the one form of
|
||||
/// its bytes. Returns the namespace and, after `--`, the track name.
|
||||
pub fn parse_rendered(s: &str) -> Option<(TrackNamespace, Option<TrackName>)> {
|
||||
let (ns_part, name_part) = match s.find("--") {
|
||||
Some(i) => (&s[..i], Some(&s[i + 2..])),
|
||||
None => (s, None),
|
||||
};
|
||||
let fields = if ns_part.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
ns_part.split('-').map(parse_field).collect::<Option<Vec<_>>>()?
|
||||
};
|
||||
let name = match name_part {
|
||||
Some(n) => Some(TrackName(parse_field(n)?)),
|
||||
None => None,
|
||||
};
|
||||
Some((TrackNamespace(fields), name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_drafts_rendering_example() {
|
||||
let ns = TrackNamespace::from_strs(["example.net", "team2", "project_x"]);
|
||||
let name = TrackName::from_str("report");
|
||||
let s = render(&ns, Some(&name));
|
||||
assert_eq!(s, "example.2enet-team2-project_x--report");
|
||||
assert_eq!(parse_rendered(&s), Some((ns, Some(name))));
|
||||
assert_eq!(parse_rendered(".61"), None, "a literal byte hex-encoded");
|
||||
assert_eq!(parse_rendered(".2"), None);
|
||||
assert_eq!(parse_rendered(".2E"), None, "uppercase hex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_limits() {
|
||||
let mut b = Vec::new();
|
||||
put_varint(&mut b, 33);
|
||||
assert_eq!(decode_namespace(&mut Reader::new(&b)), Err(WireError::TooManyNamespaceFields(33)));
|
||||
assert_eq!(decode_namespace(&mut Reader::new(&[1, 0])), Err(WireError::EmptyNamespaceField));
|
||||
let ns = TrackNamespace(vec![vec![b'a'; 4096]]);
|
||||
assert!(check_namespace(&ns).is_ok());
|
||||
assert!(check_full_name(&ns, &TrackName::from_str("x")).is_err());
|
||||
assert!(prefix_matches(&TrackNamespace::from_strs(["foo"]), &TrackNamespace::from_strs(["foo", "bar"])));
|
||||
assert!(!prefix_matches(&TrackNamespace::from_strs(["foobar"]), &TrackNamespace::from_strs(["foo", "bar"])));
|
||||
assert!(prefix_matches(&TrackNamespace(vec![]), &TrackNamespace::from_strs(["x"])));
|
||||
}
|
||||
}
|
||||
832
joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs
Normal file
832
joule-code/joule-code-rs/crates/joule-moq/src/draft22/params.rs
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
//! Message Parameters (§9.20, Table 14).
|
||||
//!
|
||||
//! A parameter is a type, delta-encoded from the previous one, and a value in
|
||||
//! the form its definition gives; the receiver must understand every one
|
||||
//! (§9.20: an unknown parameter closes the session), so a block is bounded by a
|
||||
//! count rather than a length. Two parameters' encodings are not stated in -22's
|
||||
//! prose: FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04), both durations in
|
||||
//! milliseconds. This crate reads both as a varint, as the draft encodes its
|
||||
//! other durations.
|
||||
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::io::{put_varint, Reader};
|
||||
use super::names::{decode_namespace, encode_namespace, Location};
|
||||
use super::token::Token;
|
||||
use crate::track::TrackNamespace;
|
||||
|
||||
/// Parameter types (Table 14).
|
||||
pub mod param {
|
||||
pub const OBJECT_DELIVERY_TIMEOUT: u64 = 0x02;
|
||||
pub const AUTHORIZATION_TOKEN: u64 = 0x03;
|
||||
pub const RENDEZVOUS_TIMEOUT: u64 = 0x04;
|
||||
pub const SUBGROUP_DELIVERY_TIMEOUT: u64 = 0x06;
|
||||
pub const EXPIRES: u64 = 0x08;
|
||||
pub const LARGEST_OBJECT: u64 = 0x09;
|
||||
pub const FILL_TIMEOUT: u64 = 0x0a;
|
||||
pub const FORWARD: u64 = 0x10;
|
||||
pub const SUBSCRIBER_PRIORITY: u64 = 0x20;
|
||||
pub const LOCATION_FILTER: u64 = 0x21;
|
||||
pub const GROUP_ORDER: u64 = 0x22;
|
||||
pub const FILL_PARAMETERS: u64 = 0x23;
|
||||
pub const SUBGROUP_FILTER: u64 = 0x25;
|
||||
pub const OBJECTID_FILTER: u64 = 0x26;
|
||||
pub const PRIORITY_FILTER: u64 = 0x27;
|
||||
pub const OBJECT_PROPERTY_FILTER: u64 = 0x28;
|
||||
pub const TRACK_PROPERTY_FILTER: u64 = 0x29;
|
||||
pub const NEW_GROUP_REQUEST: u64 = 0x32;
|
||||
pub const TRACK_NAMESPACE_PREFIX: u64 = 0x34;
|
||||
pub const INCLUDE_PROPERTIES: u64 = 0x35;
|
||||
|
||||
/// Every parameter type this draft defines.
|
||||
pub const KNOWN: [u64; 20] = [
|
||||
OBJECT_DELIVERY_TIMEOUT,
|
||||
AUTHORIZATION_TOKEN,
|
||||
RENDEZVOUS_TIMEOUT,
|
||||
SUBGROUP_DELIVERY_TIMEOUT,
|
||||
EXPIRES,
|
||||
LARGEST_OBJECT,
|
||||
FILL_TIMEOUT,
|
||||
FORWARD,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
GROUP_ORDER,
|
||||
FILL_PARAMETERS,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
TRACK_PROPERTY_FILTER,
|
||||
NEW_GROUP_REQUEST,
|
||||
TRACK_NAMESPACE_PREFIX,
|
||||
INCLUDE_PROPERTIES,
|
||||
];
|
||||
|
||||
/// The Range Filters (§3.3.2), which may repeat with distinct keys.
|
||||
pub fn is_range_filter(t: u64) -> bool {
|
||||
(SUBGROUP_FILTER..=TRACK_PROPERTY_FILTER).contains(&t)
|
||||
}
|
||||
|
||||
/// The name the draft gives the type.
|
||||
pub fn name(t: u64) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
OBJECT_DELIVERY_TIMEOUT => "OBJECT_DELIVERY_TIMEOUT",
|
||||
AUTHORIZATION_TOKEN => "AUTHORIZATION_TOKEN",
|
||||
RENDEZVOUS_TIMEOUT => "RENDEZVOUS_TIMEOUT",
|
||||
SUBGROUP_DELIVERY_TIMEOUT => "SUBGROUP_DELIVERY_TIMEOUT",
|
||||
EXPIRES => "EXPIRES",
|
||||
LARGEST_OBJECT => "LARGEST_OBJECT",
|
||||
FILL_TIMEOUT => "FILL_TIMEOUT",
|
||||
FORWARD => "FORWARD",
|
||||
SUBSCRIBER_PRIORITY => "SUBSCRIBER_PRIORITY",
|
||||
LOCATION_FILTER => "LOCATION_FILTER",
|
||||
GROUP_ORDER => "GROUP_ORDER",
|
||||
FILL_PARAMETERS => "FILL_PARAMETERS",
|
||||
SUBGROUP_FILTER => "SUBGROUP_FILTER",
|
||||
OBJECTID_FILTER => "OBJECTID_FILTER",
|
||||
PRIORITY_FILTER => "PRIORITY_FILTER",
|
||||
OBJECT_PROPERTY_FILTER => "OBJECT_PROPERTY_FILTER",
|
||||
TRACK_PROPERTY_FILTER => "TRACK_PROPERTY_FILTER",
|
||||
NEW_GROUP_REQUEST => "NEW_GROUP_REQUEST",
|
||||
TRACK_NAMESPACE_PREFIX => "TRACK_NAMESPACE_PREFIX",
|
||||
INCLUDE_PROPERTIES => "INCLUDE_PROPERTIES",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a parameter block sits, and so which parameters it may hold (§9.20.1:
|
||||
/// each message lists them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ParamContext {
|
||||
pub name: &'static str,
|
||||
pub allowed: &'static [u64],
|
||||
}
|
||||
|
||||
pub mod ctx {
|
||||
use super::param::*;
|
||||
use super::ParamContext;
|
||||
|
||||
pub const SUBSCRIBE: ParamContext = ParamContext {
|
||||
name: "SUBSCRIBE",
|
||||
allowed: &[
|
||||
OBJECT_DELIVERY_TIMEOUT,
|
||||
AUTHORIZATION_TOKEN,
|
||||
RENDEZVOUS_TIMEOUT,
|
||||
SUBGROUP_DELIVERY_TIMEOUT,
|
||||
FORWARD,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
GROUP_ORDER,
|
||||
FILL_PARAMETERS,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
NEW_GROUP_REQUEST,
|
||||
INCLUDE_PROPERTIES,
|
||||
],
|
||||
};
|
||||
pub const SUBSCRIBE_OK: ParamContext = ParamContext { name: "SUBSCRIBE_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
|
||||
pub const PUBLISH: ParamContext = ParamContext {
|
||||
name: "PUBLISH",
|
||||
allowed: &[
|
||||
OBJECT_DELIVERY_TIMEOUT,
|
||||
AUTHORIZATION_TOKEN,
|
||||
SUBGROUP_DELIVERY_TIMEOUT,
|
||||
EXPIRES,
|
||||
LARGEST_OBJECT,
|
||||
FORWARD,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
GROUP_ORDER,
|
||||
],
|
||||
};
|
||||
pub const PUBLISH_STATE_NOTIFY: ParamContext =
|
||||
ParamContext { name: "PUBLISH_STATE_NOTIFY", allowed: &[LARGEST_OBJECT, FORWARD, LOCATION_FILTER] };
|
||||
pub const FETCH: ParamContext = ParamContext {
|
||||
name: "FETCH",
|
||||
allowed: &[
|
||||
AUTHORIZATION_TOKEN,
|
||||
FILL_TIMEOUT,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
GROUP_ORDER,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
INCLUDE_PROPERTIES,
|
||||
],
|
||||
};
|
||||
pub const FETCH_OK: ParamContext = ParamContext { name: "FETCH_OK", allowed: &[] };
|
||||
pub const TRACK_STATUS: ParamContext = ParamContext { name: "TRACK_STATUS", allowed: &[AUTHORIZATION_TOKEN, INCLUDE_PROPERTIES] };
|
||||
pub const PUBLISH_NAMESPACE: ParamContext = ParamContext { name: "PUBLISH_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
|
||||
pub const SUBSCRIBE_NAMESPACE: ParamContext = ParamContext { name: "SUBSCRIBE_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
|
||||
pub const SUBSCRIBE_TRACKS: ParamContext = ParamContext {
|
||||
name: "SUBSCRIBE_TRACKS",
|
||||
allowed: &[
|
||||
AUTHORIZATION_TOKEN,
|
||||
FORWARD,
|
||||
GROUP_ORDER,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
TRACK_PROPERTY_FILTER,
|
||||
INCLUDE_PROPERTIES,
|
||||
],
|
||||
};
|
||||
/// REQUEST_UPDATE, read before its request is known: the union of the lists
|
||||
/// of §9.5. [`super::Parameters::check_allowed`] narrows it.
|
||||
pub const REQUEST_UPDATE: ParamContext = ParamContext {
|
||||
name: "REQUEST_UPDATE",
|
||||
allowed: &[
|
||||
OBJECT_DELIVERY_TIMEOUT,
|
||||
AUTHORIZATION_TOKEN,
|
||||
SUBGROUP_DELIVERY_TIMEOUT,
|
||||
FORWARD,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
FILL_PARAMETERS,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
TRACK_PROPERTY_FILTER,
|
||||
NEW_GROUP_REQUEST,
|
||||
TRACK_NAMESPACE_PREFIX,
|
||||
],
|
||||
};
|
||||
pub const UPDATE_SUBSCRIPTION: ParamContext = ParamContext {
|
||||
name: "REQUEST_UPDATE for a subscription",
|
||||
allowed: &[
|
||||
OBJECT_DELIVERY_TIMEOUT,
|
||||
AUTHORIZATION_TOKEN,
|
||||
SUBGROUP_DELIVERY_TIMEOUT,
|
||||
FORWARD,
|
||||
SUBSCRIBER_PRIORITY,
|
||||
LOCATION_FILTER,
|
||||
FILL_PARAMETERS,
|
||||
SUBGROUP_FILTER,
|
||||
OBJECTID_FILTER,
|
||||
PRIORITY_FILTER,
|
||||
OBJECT_PROPERTY_FILTER,
|
||||
NEW_GROUP_REQUEST,
|
||||
],
|
||||
};
|
||||
pub const UPDATE_FETCH: ParamContext = ParamContext { name: "REQUEST_UPDATE for a FETCH", allowed: &[AUTHORIZATION_TOKEN, SUBSCRIBER_PRIORITY] };
|
||||
pub const UPDATE_PUBLISH_NAMESPACE: ParamContext = ParamContext { name: "REQUEST_UPDATE for a PUBLISH_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN] };
|
||||
pub const UPDATE_SUBSCRIBE_NAMESPACE: ParamContext =
|
||||
ParamContext { name: "REQUEST_UPDATE for a SUBSCRIBE_NAMESPACE", allowed: &[AUTHORIZATION_TOKEN, TRACK_NAMESPACE_PREFIX] };
|
||||
pub const UPDATE_SUBSCRIBE_TRACKS: ParamContext = ParamContext {
|
||||
name: "REQUEST_UPDATE for a SUBSCRIBE_TRACKS",
|
||||
allowed: &[AUTHORIZATION_TOKEN, FORWARD, TRACK_PROPERTY_FILTER, TRACK_NAMESPACE_PREFIX],
|
||||
};
|
||||
/// REQUEST_OK, read before its request is known: the union of §9.3's lists.
|
||||
pub const REQUEST_OK: ParamContext = ParamContext { name: "REQUEST_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
|
||||
pub const PUBLISH_OK: ParamContext = ParamContext { name: "PUBLISH_OK", allowed: &[EXPIRES] };
|
||||
pub const REQUEST_UPDATE_OK: ParamContext = ParamContext { name: "REQUEST_UPDATE_OK", allowed: &[EXPIRES, LARGEST_OBJECT] };
|
||||
pub const TRACK_STATUS_OK: ParamContext = ParamContext { name: "TRACK_STATUS_OK", allowed: &[LARGEST_OBJECT] };
|
||||
pub const NAMESPACE_OK: ParamContext =
|
||||
ParamContext { name: "SUBSCRIBE_NAMESPACE_OK, SUBSCRIBE_TRACKS_OK or PUBLISH_NAMESPACE_OK", allowed: &[EXPIRES] };
|
||||
/// Inside FILL_PARAMETERS (§9.20.15, Table 7).
|
||||
pub const FILL_PARAMETERS_INNER: ParamContext = ParamContext {
|
||||
name: "FILL_PARAMETERS",
|
||||
allowed: &[FILL_TIMEOUT, SUBSCRIBER_PRIORITY, LOCATION_FILTER, GROUP_ORDER, SUBGROUP_FILTER, OBJECTID_FILTER, PRIORITY_FILTER, OBJECT_PROPERTY_FILTER],
|
||||
};
|
||||
}
|
||||
|
||||
/// Group Order (§9.20.8, §10.5).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum GroupOrder {
|
||||
Ascending = 1,
|
||||
Descending = 2,
|
||||
}
|
||||
|
||||
impl GroupOrder {
|
||||
pub fn from_u8(v: u8, what: &'static str) -> WireResult<Self> {
|
||||
match v {
|
||||
1 => Ok(GroupOrder::Ascending),
|
||||
2 => Ok(GroupOrder::Descending),
|
||||
_ => Err(WireError::InvalidValue { what, value: v as u64 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Location Filter (§3.3.1, §9.20.9, Table 6), as it is on the wire.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum LocationFilter {
|
||||
/// 0x00: no filter.
|
||||
None,
|
||||
/// 0x01: from `{Largest Object.Group + 1 - start_group, 0}`, open-ended.
|
||||
RelativeStart { start_group: u64 },
|
||||
/// 0x02: from `start`, open-ended.
|
||||
AbsoluteStart { start: Location },
|
||||
/// 0x03: from `start` to the last object of group `start.group + end_group_delta`.
|
||||
AbsoluteStartGroupEnd { start: Location, end_group_delta: u64 },
|
||||
/// 0x04: from `start` to `{start.group + end_group_delta, end_object}`.
|
||||
AbsoluteRange { start: Location, end_group_delta: u64, end_object: u64 },
|
||||
/// 0x05: from the Next Object, open-ended.
|
||||
NextObject,
|
||||
}
|
||||
|
||||
impl LocationFilter {
|
||||
pub fn type_code(&self) -> u64 {
|
||||
match self {
|
||||
LocationFilter::None => 0,
|
||||
LocationFilter::RelativeStart { .. } => 1,
|
||||
LocationFilter::AbsoluteStart { .. } => 2,
|
||||
LocationFilter::AbsoluteStartGroupEnd { .. } => 3,
|
||||
LocationFilter::AbsoluteRange { .. } => 4,
|
||||
LocationFilter::NextObject => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute end group of a filter that has one.
|
||||
pub fn end_group(&self) -> Option<u64> {
|
||||
match self {
|
||||
LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta } | LocationFilter::AbsoluteRange { start, end_group_delta, .. } => {
|
||||
start.group.checked_add(*end_group_delta)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The start of a relative filter given the Largest Object's group, clamped
|
||||
/// to 0 and to 2^64-1 as §9.20.9 says.
|
||||
pub fn relative_start(start_group: u64, largest_group: Option<u64>) -> u64 {
|
||||
let next = largest_group.map_or(0u128, |g| g as u128 + 1);
|
||||
next.saturating_sub(start_group as u128).min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
fn encode(&self, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
if self.end_group().is_none() && matches!(self, LocationFilter::AbsoluteStartGroupEnd { .. } | LocationFilter::AbsoluteRange { .. }) {
|
||||
return Err(WireError::Unencodable { what: "LOCATION_FILTER", reason: "StartGroup + EndGroupDelta past 2^64-1" });
|
||||
}
|
||||
put_varint(out, self.type_code());
|
||||
match self {
|
||||
LocationFilter::None | LocationFilter::NextObject => {}
|
||||
LocationFilter::RelativeStart { start_group } => put_varint(out, *start_group),
|
||||
LocationFilter::AbsoluteStart { start } => start.encode(out),
|
||||
LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta } => {
|
||||
start.encode(out);
|
||||
put_varint(out, *end_group_delta);
|
||||
}
|
||||
LocationFilter::AbsoluteRange { start, end_group_delta, end_object } => {
|
||||
start.encode(out);
|
||||
put_varint(out, *end_group_delta);
|
||||
put_varint(out, *end_object);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode(r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
const W: &str = "LOCATION_FILTER";
|
||||
let t = r.varint(W)?;
|
||||
let f = match t {
|
||||
0 => LocationFilter::None,
|
||||
1 => LocationFilter::RelativeStart { start_group: r.varint(W)? },
|
||||
2 => LocationFilter::AbsoluteStart { start: Location::decode(r, W)? },
|
||||
3 => LocationFilter::AbsoluteStartGroupEnd { start: Location::decode(r, W)?, end_group_delta: r.varint(W)? },
|
||||
4 => LocationFilter::AbsoluteRange { start: Location::decode(r, W)?, end_group_delta: r.varint(W)?, end_object: r.varint(W)? },
|
||||
5 => LocationFilter::NextObject,
|
||||
v => return Err(WireError::InvalidValue { what: "Location Filter Type", value: v }),
|
||||
};
|
||||
if matches!(f, LocationFilter::AbsoluteStartGroupEnd { .. } | LocationFilter::AbsoluteRange { .. }) && f.end_group().is_none() {
|
||||
return Err(WireError::Overflow { what: "LOCATION_FILTER end group" });
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// One inclusive Range of a Range Filter (§8.6): `end` is `None` only for an
|
||||
/// open-ended last range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Range {
|
||||
pub start: u64,
|
||||
pub end: Option<u64>,
|
||||
}
|
||||
|
||||
/// The body of a Range Filter parameter of Length > 0 (§8.6, §9.20.10-14):
|
||||
/// its SetID, the property type for the two property filters, and its ranges
|
||||
/// as absolute values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct RangeFilter {
|
||||
pub set_id: u8,
|
||||
/// Present exactly for OBJECT_PROPERTY_FILTER and TRACK_PROPERTY_FILTER.
|
||||
pub property_type: Option<u64>,
|
||||
pub ranges: Vec<Range>,
|
||||
}
|
||||
|
||||
impl RangeFilter {
|
||||
fn encode_body(&self, ty: u64, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
let is_prop = ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER;
|
||||
if is_prop != self.property_type.is_some() {
|
||||
return Err(WireError::Unencodable { what: "range filter", reason: "a property type exactly on the property filters" });
|
||||
}
|
||||
out.push(self.set_id);
|
||||
if let Some(p) = self.property_type {
|
||||
put_varint(out, p);
|
||||
}
|
||||
let mut prev_end = 0u64;
|
||||
for (i, rg) in self.ranges.iter().enumerate() {
|
||||
let last = i + 1 == self.ranges.len();
|
||||
if rg.start < prev_end || (i > 0 && self.ranges[i - 1].end.is_none()) {
|
||||
return Err(WireError::Unencodable { what: "range filter", reason: "ranges in order, only the last open-ended" });
|
||||
}
|
||||
put_varint(out, rg.start - prev_end);
|
||||
match rg.end {
|
||||
Some(e) if e >= rg.start => {
|
||||
put_varint(out, e - rg.start);
|
||||
prev_end = e;
|
||||
}
|
||||
Some(_) => return Err(WireError::Unencodable { what: "range filter", reason: "a range that ends before it starts" }),
|
||||
None if last => {}
|
||||
None => return Err(WireError::Unencodable { what: "range filter", reason: "only the last range is open-ended" }),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_body(ty: u64, r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
const W: &str = "range filter";
|
||||
let set_id = r.u8(W)?;
|
||||
let property_type = if ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER {
|
||||
let p = r.varint(W)?;
|
||||
if p % 2 == 1 {
|
||||
return Err(WireError::InvalidFilter { reason: "a property filter names an odd property type" });
|
||||
}
|
||||
Some(p)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut ranges = Vec::new();
|
||||
let mut prev_end = 0u64;
|
||||
while !r.is_empty() {
|
||||
let start = prev_end.checked_add(r.varint(W)?).ok_or(WireError::InvalidFilter { reason: "a range start past 2^64-1" })?;
|
||||
let end = if r.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let e = start.checked_add(r.varint(W)?).ok_or(WireError::InvalidFilter { reason: "a range end past 2^64-1" })?;
|
||||
prev_end = e;
|
||||
Some(e)
|
||||
};
|
||||
ranges.push(Range { start, end });
|
||||
}
|
||||
if ty == param::PRIORITY_FILTER && ranges.iter().any(|rg| rg.start > 255 || rg.end.is_some_and(|e| e > 255)) {
|
||||
return Err(WireError::InvalidFilter { reason: "a publisher priority above 255" });
|
||||
}
|
||||
Ok(RangeFilter { set_id, property_type, ranges })
|
||||
}
|
||||
}
|
||||
|
||||
/// One Message Parameter, its value typed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Parameter {
|
||||
ObjectDeliveryTimeout(u64),
|
||||
AuthorizationToken(Token),
|
||||
RendezvousTimeout(u64),
|
||||
SubgroupDeliveryTimeout(u64),
|
||||
Expires(u64),
|
||||
LargestObject(Location),
|
||||
FillTimeout(u64),
|
||||
Forward(bool),
|
||||
SubscriberPriority(u8),
|
||||
LocationFilter(LocationFilter),
|
||||
GroupOrder(GroupOrder),
|
||||
FillParameters(Parameters),
|
||||
/// A Range Filter: `type` is one of 0x25-0x29; `None` is Length 0, which
|
||||
/// removes the filter in REQUEST_UPDATE (§3.3.2).
|
||||
RangeFilter { ty: u64, filter: Option<RangeFilter> },
|
||||
NewGroupRequest(u64),
|
||||
TrackNamespacePrefix(TrackNamespace),
|
||||
IncludeProperties(bool),
|
||||
}
|
||||
|
||||
impl Parameter {
|
||||
pub fn type_code(&self) -> u64 {
|
||||
use param::*;
|
||||
match self {
|
||||
Parameter::ObjectDeliveryTimeout(_) => OBJECT_DELIVERY_TIMEOUT,
|
||||
Parameter::AuthorizationToken(_) => AUTHORIZATION_TOKEN,
|
||||
Parameter::RendezvousTimeout(_) => RENDEZVOUS_TIMEOUT,
|
||||
Parameter::SubgroupDeliveryTimeout(_) => SUBGROUP_DELIVERY_TIMEOUT,
|
||||
Parameter::Expires(_) => EXPIRES,
|
||||
Parameter::LargestObject(_) => LARGEST_OBJECT,
|
||||
Parameter::FillTimeout(_) => FILL_TIMEOUT,
|
||||
Parameter::Forward(_) => FORWARD,
|
||||
Parameter::SubscriberPriority(_) => SUBSCRIBER_PRIORITY,
|
||||
Parameter::LocationFilter(_) => LOCATION_FILTER,
|
||||
Parameter::GroupOrder(_) => GROUP_ORDER,
|
||||
Parameter::FillParameters(_) => FILL_PARAMETERS,
|
||||
Parameter::RangeFilter { ty, .. } => *ty,
|
||||
Parameter::NewGroupRequest(_) => NEW_GROUP_REQUEST,
|
||||
Parameter::TrackNamespacePrefix(_) => TRACK_NAMESPACE_PREFIX,
|
||||
Parameter::IncludeProperties(_) => INCLUDE_PROPERTIES,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_value(&self, out: &mut Vec<u8>) -> WireResult<()> {
|
||||
match self {
|
||||
Parameter::ObjectDeliveryTimeout(v)
|
||||
| Parameter::RendezvousTimeout(v)
|
||||
| Parameter::SubgroupDeliveryTimeout(v)
|
||||
| Parameter::Expires(v)
|
||||
| Parameter::FillTimeout(v)
|
||||
| Parameter::NewGroupRequest(v) => put_varint(out, *v),
|
||||
Parameter::AuthorizationToken(t) => {
|
||||
let b = t.to_bytes();
|
||||
put_varint(out, b.len() as u64);
|
||||
out.extend_from_slice(&b);
|
||||
}
|
||||
Parameter::LargestObject(l) => l.encode(out),
|
||||
Parameter::Forward(b) | Parameter::IncludeProperties(b) => out.push(*b as u8),
|
||||
Parameter::SubscriberPriority(p) => out.push(*p),
|
||||
Parameter::LocationFilter(f) => f.encode(out)?,
|
||||
Parameter::GroupOrder(g) => out.push(*g as u8),
|
||||
Parameter::FillParameters(ps) => {
|
||||
let mut inner = Vec::new();
|
||||
ps.encode_seq(&mut inner, Some(&ctx::FILL_PARAMETERS_INNER))?;
|
||||
put_varint(out, inner.len() as u64);
|
||||
out.extend_from_slice(&inner);
|
||||
}
|
||||
Parameter::RangeFilter { ty, filter } => {
|
||||
if !param::is_range_filter(*ty) {
|
||||
return Err(WireError::Unencodable { what: "range filter", reason: "its type is not 0x25 to 0x29" });
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(f) = filter {
|
||||
f.encode_body(*ty, &mut body)?;
|
||||
}
|
||||
put_varint(out, body.len() as u64);
|
||||
out.extend_from_slice(&body);
|
||||
}
|
||||
Parameter::TrackNamespacePrefix(ns) => encode_namespace(ns, out)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_value(ty: u64, r: &mut Reader<'_>) -> WireResult<Self> {
|
||||
use param::*;
|
||||
let flag = |r: &mut Reader<'_>, what: &'static str| -> WireResult<bool> {
|
||||
match r.u8(what)? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
v => Err(WireError::InvalidValue { what, value: v as u64 }),
|
||||
}
|
||||
};
|
||||
Ok(match ty {
|
||||
OBJECT_DELIVERY_TIMEOUT => Parameter::ObjectDeliveryTimeout(r.varint("OBJECT_DELIVERY_TIMEOUT")?),
|
||||
AUTHORIZATION_TOKEN => Parameter::AuthorizationToken(Token::decode(r.length_prefixed(None, "AUTHORIZATION_TOKEN")?)?),
|
||||
RENDEZVOUS_TIMEOUT => Parameter::RendezvousTimeout(r.varint("RENDEZVOUS_TIMEOUT")?),
|
||||
SUBGROUP_DELIVERY_TIMEOUT => Parameter::SubgroupDeliveryTimeout(r.varint("SUBGROUP_DELIVERY_TIMEOUT")?),
|
||||
EXPIRES => Parameter::Expires(r.varint("EXPIRES")?),
|
||||
LARGEST_OBJECT => Parameter::LargestObject(Location::decode(r, "LARGEST_OBJECT")?),
|
||||
FILL_TIMEOUT => Parameter::FillTimeout(r.varint("FILL_TIMEOUT")?),
|
||||
FORWARD => Parameter::Forward(flag(r, "FORWARD")?),
|
||||
SUBSCRIBER_PRIORITY => Parameter::SubscriberPriority(r.u8("SUBSCRIBER_PRIORITY")?),
|
||||
LOCATION_FILTER => Parameter::LocationFilter(LocationFilter::decode(r)?),
|
||||
GROUP_ORDER => Parameter::GroupOrder(GroupOrder::from_u8(r.u8("GROUP_ORDER")?, "GROUP_ORDER")?),
|
||||
FILL_PARAMETERS => {
|
||||
let b = r.length_prefixed(None, "FILL_PARAMETERS")?;
|
||||
Parameter::FillParameters(Parameters::decode_seq(&mut Reader::new(b), &ctx::FILL_PARAMETERS_INNER)?)
|
||||
}
|
||||
SUBGROUP_FILTER..=TRACK_PROPERTY_FILTER => {
|
||||
let b = r.length_prefixed(None, "range filter")?;
|
||||
let filter = if b.is_empty() { None } else { Some(RangeFilter::decode_body(ty, &mut Reader::new(b))?) };
|
||||
Parameter::RangeFilter { ty, filter }
|
||||
}
|
||||
NEW_GROUP_REQUEST => Parameter::NewGroupRequest(r.varint("NEW_GROUP_REQUEST")?),
|
||||
TRACK_NAMESPACE_PREFIX => Parameter::TrackNamespacePrefix(decode_namespace(r)?),
|
||||
INCLUDE_PROPERTIES => Parameter::IncludeProperties(flag(r, "INCLUDE_PROPERTIES")?),
|
||||
other => return Err(WireError::UnknownParameter(other)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The key §3.3.2 forbids repeating, for a Range Filter.
|
||||
fn filter_key(&self) -> Option<(u64, Option<u8>, Option<u64>)> {
|
||||
match self {
|
||||
Parameter::RangeFilter { ty, filter } => Some((*ty, filter.as_ref().map(|f| f.set_id), filter.as_ref().and_then(|f| f.property_type))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A block of Message Parameters, in the order they appear (on the wire,
|
||||
/// ascending type).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Parameters(pub Vec<Parameter>);
|
||||
|
||||
impl Parameters {
|
||||
pub fn new() -> Self {
|
||||
Parameters(Vec::new())
|
||||
}
|
||||
|
||||
pub fn with(mut self, p: Parameter) -> Self {
|
||||
self.0.push(p);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn push(&mut self, p: Parameter) -> &mut Self {
|
||||
self.0.push(p);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// `Number of Parameters` then the parameters (the form in every message).
|
||||
pub fn encode_counted(&self, out: &mut Vec<u8>, context: Option<&ParamContext>) -> WireResult<()> {
|
||||
put_varint(out, self.0.len() as u64);
|
||||
self.encode_seq(out, context)
|
||||
}
|
||||
|
||||
/// The parameters alone, in ascending type order (a stable sort), each type
|
||||
/// a delta from the previous one. With a context, refused where a parameter
|
||||
/// is not allowed or repeated where it may not be.
|
||||
pub fn encode_seq(&self, out: &mut Vec<u8>, context: Option<&ParamContext>) -> WireResult<()> {
|
||||
if let Some(c) = context {
|
||||
self.check(c)?;
|
||||
}
|
||||
let mut sorted: Vec<&Parameter> = self.0.iter().collect();
|
||||
sorted.sort_by_key(|p| p.type_code());
|
||||
let mut prev = 0u64;
|
||||
for p in sorted {
|
||||
let t = p.type_code();
|
||||
put_varint(out, t - prev);
|
||||
prev = t;
|
||||
p.encode_value(out)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read `Number of Parameters` then that many parameters, in `context`.
|
||||
pub fn decode_counted(r: &mut Reader<'_>, context: &ParamContext) -> WireResult<Self> {
|
||||
let n = r.varint("number of parameters")?;
|
||||
let mut st = DecodeState::default();
|
||||
for _ in 0..n {
|
||||
st.one(r, context)?;
|
||||
}
|
||||
Ok(Parameters(st.out))
|
||||
}
|
||||
|
||||
/// Read parameters until `r` is empty (the value of FILL_PARAMETERS).
|
||||
pub fn decode_seq(r: &mut Reader<'_>, context: &ParamContext) -> WireResult<Self> {
|
||||
let mut st = DecodeState::default();
|
||||
while !r.is_empty() {
|
||||
st.one(r, context)?;
|
||||
}
|
||||
Ok(Parameters(st.out))
|
||||
}
|
||||
|
||||
/// The rules a block must keep in `context`, in the order a decoder meets
|
||||
/// them: each parameter allowed there; a parameter other than a Range Filter
|
||||
/// or AUTHORIZATION_TOKEN at most once; a Range Filter's (type, SetID,
|
||||
/// property type) at most once.
|
||||
pub fn check(&self, context: &ParamContext) -> WireResult<()> {
|
||||
let mut st = DecodeState::default();
|
||||
for p in &self.0 {
|
||||
st.admit(p.clone(), context)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Narrow a block read under a union context (REQUEST_UPDATE, REQUEST_OK)
|
||||
/// to the request it answers or updates.
|
||||
pub fn check_allowed(&self, context: &ParamContext) -> WireResult<()> {
|
||||
self.check(context)
|
||||
}
|
||||
|
||||
pub fn get(&self, ty: u64) -> Option<&Parameter> {
|
||||
self.0.iter().find(|p| p.type_code() == ty)
|
||||
}
|
||||
|
||||
/// The number of Ranges across the block's Range Filters, which
|
||||
/// MAX_FILTER_RANGES bounds (§9.1.6).
|
||||
pub fn range_count(&self) -> u64 {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Parameter::RangeFilter { filter: Some(f), .. } => f.ranges.len() as u64,
|
||||
_ => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn location_filter(&self) -> Option<LocationFilter> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::LocationFilter(f) => Some(*f),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn largest_object(&self) -> Option<Location> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::LargestObject(l) => Some(*l),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn group_order(&self) -> Option<GroupOrder> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::GroupOrder(g) => Some(*g),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// FORWARD; 1 (forward) when absent from a message other than an update
|
||||
/// (§9.20.18).
|
||||
pub fn forward(&self) -> Option<bool> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::Forward(b) => Some(*b),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// SUBSCRIBER_PRIORITY; the publisher uses 128 when it is absent (§9.20.7).
|
||||
pub fn subscriber_priority(&self) -> Option<u8> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::SubscriberPriority(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn expires(&self) -> Option<u64> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::Expires(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn object_delivery_timeout(&self) -> Option<u64> {
|
||||
self.0.iter().find_map(|p| match p {
|
||||
Parameter::ObjectDeliveryTimeout(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
pub fn range_filters(&self) -> impl Iterator<Item = (u64, Option<&RangeFilter>)> {
|
||||
self.0.iter().filter_map(|p| match p {
|
||||
Parameter::RangeFilter { ty, filter } => Some((*ty, filter.as_ref())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DecodeState {
|
||||
out: Vec<Parameter>,
|
||||
prev: u64,
|
||||
}
|
||||
|
||||
impl DecodeState {
|
||||
fn one(&mut self, r: &mut Reader<'_>, context: &ParamContext) -> WireResult<()> {
|
||||
let delta = r.varint("parameter type")?;
|
||||
let ty = self.prev.checked_add(delta).ok_or(WireError::TypeOverflow { what: "message parameters" })?;
|
||||
self.prev = ty;
|
||||
if param::name(ty).is_none() {
|
||||
return Err(WireError::UnknownParameter(ty));
|
||||
}
|
||||
if !context.allowed.contains(&ty) {
|
||||
return Err(WireError::ParameterNotAllowed { param: ty, context: context.name });
|
||||
}
|
||||
let p = Parameter::decode_value(ty, r)?;
|
||||
self.admit_checked(p)
|
||||
}
|
||||
|
||||
fn admit(&mut self, p: Parameter, context: &ParamContext) -> WireResult<()> {
|
||||
let ty = p.type_code();
|
||||
if !context.allowed.contains(&ty) {
|
||||
return Err(WireError::ParameterNotAllowed { param: ty, context: context.name });
|
||||
}
|
||||
self.admit_checked(p)
|
||||
}
|
||||
|
||||
fn admit_checked(&mut self, p: Parameter) -> WireResult<()> {
|
||||
let ty = p.type_code();
|
||||
if let Some(key) = p.filter_key() {
|
||||
if self.out.iter().any(|q| q.filter_key() == Some(key)) {
|
||||
return Err(WireError::DuplicateFilter { param: ty, set_id: key.1.unwrap_or(0), property: key.2 });
|
||||
}
|
||||
} else if ty != param::AUTHORIZATION_TOKEN && self.out.iter().any(|q| q.type_code() == ty) {
|
||||
return Err(WireError::Duplicate { what: "message parameter", ty });
|
||||
}
|
||||
self.out.push(p);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round(ps: &Parameters, c: &ParamContext) -> Parameters {
|
||||
let mut b = Vec::new();
|
||||
ps.encode_counted(&mut b, Some(c)).unwrap();
|
||||
let mut r = Reader::new(&b);
|
||||
let back = Parameters::decode_counted(&mut r, c).unwrap();
|
||||
assert!(r.is_empty());
|
||||
back
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_parameters_round_trip_in_type_order() {
|
||||
let ps = Parameters::new()
|
||||
.with(Parameter::LocationFilter(LocationFilter::NextObject))
|
||||
.with(Parameter::Forward(true))
|
||||
.with(Parameter::RangeFilter {
|
||||
ty: param::OBJECT_PROPERTY_FILTER,
|
||||
filter: Some(RangeFilter { set_id: 0, property_type: Some(0x3a40), ranges: vec![Range { start: 0, end: Some(2) }] }),
|
||||
})
|
||||
.with(Parameter::RangeFilter { ty: param::SUBGROUP_FILTER, filter: Some(RangeFilter { set_id: 1, property_type: None, ranges: vec![Range { start: 3, end: Some(5) }, Range { start: 10, end: None }] }) })
|
||||
.with(Parameter::FillParameters(Parameters::new().with(Parameter::LocationFilter(LocationFilter::RelativeStart { start_group: 1 }))));
|
||||
let back = round(&ps, &ctx::SUBSCRIBE);
|
||||
let mut sorted = ps.0.clone();
|
||||
sorted.sort_by_key(|p| p.type_code());
|
||||
assert_eq!(back.0, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drafts_range_example() {
|
||||
// §8.6: ranges 3-5 and 10-15 encode as Start=3, End=2, Start=5, End=5.
|
||||
let f = RangeFilter { set_id: 0, property_type: None, ranges: vec![Range { start: 3, end: Some(5) }, Range { start: 10, end: Some(15) }] };
|
||||
let mut b = Vec::new();
|
||||
f.encode_body(param::OBJECTID_FILTER, &mut b).unwrap();
|
||||
assert_eq!(b, vec![0, 3, 2, 5, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals() {
|
||||
// A parameter not allowed in SUBSCRIBE_OK.
|
||||
let ps = Parameters::new().with(Parameter::Forward(true));
|
||||
assert!(matches!(ps.check(&ctx::SUBSCRIBE_OK), Err(WireError::ParameterNotAllowed { .. })));
|
||||
// FORWARD twice.
|
||||
let b = [2u8, param::FORWARD as u8, 1, 0, 1];
|
||||
assert_eq!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::Duplicate { what: "message parameter", ty: param::FORWARD }));
|
||||
// A filter repeated with the same SetID.
|
||||
let b = [2u8, 0x26, 2, 0, 1, 0, 2, 0, 4];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::DuplicateFilter { .. })));
|
||||
// An unknown parameter.
|
||||
let b = [1u8, 0x05, 0];
|
||||
assert_eq!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::UnknownParameter(5)));
|
||||
// FORWARD = 2.
|
||||
let b = [1u8, 0x10, 2];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidValue { .. })));
|
||||
// A Location Filter Type of 6.
|
||||
let b = [1u8, 0x21, 6];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidValue { .. })));
|
||||
// FILL_PARAMETERS holding FORWARD.
|
||||
let b = [1u8, 0x23, 2, 0x10, 1];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::ParameterNotAllowed { .. })));
|
||||
// A priority filter above 255.
|
||||
let b = [1u8, 0x27, 4, 0, 0x81, 0x00, 0];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::InvalidFilter { .. })));
|
||||
// A count far beyond the bytes: refused as truncated, without allocating it.
|
||||
let b = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
|
||||
assert!(matches!(Parameters::decode_counted(&mut Reader::new(&b), &ctx::SUBSCRIBE), Err(WireError::Truncated { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_start_clamps() {
|
||||
assert_eq!(LocationFilter::relative_start(0, Some(9)), 10);
|
||||
assert_eq!(LocationFilter::relative_start(1, Some(9)), 9);
|
||||
assert_eq!(LocationFilter::relative_start(50, Some(9)), 0);
|
||||
assert_eq!(LocationFilter::relative_start(0, Some(u64::MAX)), u64::MAX);
|
||||
assert_eq!(LocationFilter::relative_start(0, None), 0);
|
||||
}
|
||||
}
|
||||
415
joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs
Normal file
415
joule-code/joule-code-rs/crates/joule-moq/src/draft22/request.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
//! Request IDs, request streams and their flow control (§6.3, §6.4.2, §9.1.7,
|
||||
//! §9.5).
|
||||
//!
|
||||
//! In -22 every request is a bidirectional stream of its own, so the number of
|
||||
//! requests in flight is bounded by the transport's stream limits (QUIC's
|
||||
//! MAX_STREAMS); the draft keeps no request-level credit of its own. What it
|
||||
//! does bound is REQUEST_UPDATE: MAX_REQUEST_UPDATES (§9.1.7) caps the updates
|
||||
//! outstanding on one request stream. This module holds:
|
||||
//!
|
||||
//! - [`RequestIds`], an endpoint's own Request IDs: even from 0 for a client,
|
||||
//! odd from 1 for a server, each 2 above the last (§6.4.2.1);
|
||||
//! - [`PeerRequestIds`], which refuses a peer's Request ID of the wrong parity
|
||||
//! or seen before (INVALID_REQUEST_ID);
|
||||
//! - [`ControlStreamState`], the order on a control stream: SETUP, then GOAWAY
|
||||
//! at most once (§6.3, §9.2);
|
||||
//! - [`RequestStream`], the messages each side of one request stream may send
|
||||
//! and when (§6.3, §6.4.2, §3.6, §4.2, §9), with MAX_REQUEST_UPDATES credit.
|
||||
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use super::codes::SessionError;
|
||||
use super::control::ControlMessage;
|
||||
use super::error::{WireError, WireResult};
|
||||
use crate::track::TrackNamespace;
|
||||
|
||||
/// Which end of the transport session an endpoint is (§1.3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Perspective {
|
||||
Client,
|
||||
Server,
|
||||
}
|
||||
|
||||
impl Perspective {
|
||||
pub fn peer(self) -> Perspective {
|
||||
match self {
|
||||
Perspective::Client => Perspective::Server,
|
||||
Perspective::Server => Perspective::Client,
|
||||
}
|
||||
}
|
||||
|
||||
/// The low bit of this endpoint's Request IDs.
|
||||
pub fn request_id_parity(self) -> u64 {
|
||||
match self {
|
||||
Perspective::Client => 0,
|
||||
Perspective::Server => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seven requests that open a request stream (§6.3).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum RequestKind {
|
||||
Subscribe,
|
||||
Publish,
|
||||
Fetch,
|
||||
TrackStatus,
|
||||
PublishNamespace,
|
||||
SubscribeNamespace,
|
||||
SubscribeTracks,
|
||||
}
|
||||
|
||||
/// An endpoint's own Request IDs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestIds {
|
||||
next: Option<u64>,
|
||||
}
|
||||
|
||||
impl RequestIds {
|
||||
pub fn new(me: Perspective) -> Self {
|
||||
RequestIds { next: Some(me.request_id_parity()) }
|
||||
}
|
||||
|
||||
/// The next Request ID, or `None` once the 64-bit space is spent.
|
||||
pub fn next(&mut self) -> Option<u64> {
|
||||
let id = self.next?;
|
||||
self.next = id.checked_add(2);
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The peer's Request IDs seen so far.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PeerRequestIds {
|
||||
parity: u64,
|
||||
/// Every ID of the peer's parity below this has been seen.
|
||||
floor: u64,
|
||||
/// IDs seen at or above `floor`.
|
||||
above: BTreeSet<u64>,
|
||||
window: usize,
|
||||
}
|
||||
|
||||
/// How many of the peer's Request IDs may arrive ahead of the lowest one not
|
||||
/// yet seen before this implementation refuses to track more.
|
||||
pub const PEER_REQUEST_ID_WINDOW: usize = 4096;
|
||||
|
||||
impl PeerRequestIds {
|
||||
/// The IDs `peer` sends.
|
||||
pub fn new(peer: Perspective) -> Self {
|
||||
PeerRequestIds { parity: peer.request_id_parity(), floor: peer.request_id_parity(), above: BTreeSet::new(), window: PEER_REQUEST_ID_WINDOW }
|
||||
}
|
||||
|
||||
/// Admit one Request ID: refused with INVALID_REQUEST_ID when its low bit is
|
||||
/// not the peer's or it was seen before (§6.4.2.1).
|
||||
pub fn accept(&mut self, id: u64) -> WireResult<()> {
|
||||
if id % 2 != self.parity {
|
||||
return Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, reason: "a Request ID of the other endpoint's parity" });
|
||||
}
|
||||
if id < self.floor || self.above.contains(&id) {
|
||||
return Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, reason: "a Request ID seen before" });
|
||||
}
|
||||
if self.above.len() >= self.window {
|
||||
return Err(WireError::LocalLimit { what: "peer Request IDs ahead of the lowest unseen", len: self.above.len() as u64 + 1, limit: self.window as u64 });
|
||||
}
|
||||
self.above.insert(id);
|
||||
while self.above.remove(&self.floor) {
|
||||
match self.floor.checked_add(2) {
|
||||
Some(f) => self.floor = f,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The order of messages on a control stream (§6.3, §9.2).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ControlStreamState {
|
||||
setup_seen: bool,
|
||||
goaway_seen: bool,
|
||||
}
|
||||
|
||||
impl ControlStreamState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Admit the next message received on the peer's control stream.
|
||||
pub fn on_message(&mut self, m: &ControlMessage) -> WireResult<()> {
|
||||
match (self.setup_seen, m) {
|
||||
(false, ControlMessage::Setup(_)) => {
|
||||
self.setup_seen = true;
|
||||
Ok(())
|
||||
}
|
||||
(false, _) => Err(WireError::UnexpectedMessage("a control stream that does not begin with SETUP")),
|
||||
(true, ControlMessage::Goaway(_)) if !self.goaway_seen => {
|
||||
self.goaway_seen = true;
|
||||
Ok(())
|
||||
}
|
||||
(true, ControlMessage::Goaway(_)) => Err(WireError::UnexpectedMessage("a second GOAWAY on the control stream")),
|
||||
(true, _) => Err(WireError::UnexpectedMessage("a message other than GOAWAY on the control stream after SETUP")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of a request stream an endpoint is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
/// It sent the request.
|
||||
Requester,
|
||||
/// It received the request.
|
||||
Responder,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// No response yet.
|
||||
AwaitingResponse,
|
||||
/// Accepted: the stream carries what the request type allows.
|
||||
Accepted,
|
||||
/// Refused, or the request is done: nothing more on that direction.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// One request stream, as one endpoint sees it: the messages each side may
|
||||
/// send and in what order, the updates outstanding each way against
|
||||
/// MAX_REQUEST_UPDATES, and the namespaces a SUBSCRIBE_NAMESPACE has announced.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestStream {
|
||||
kind: RequestKind,
|
||||
me: Side,
|
||||
/// The responder's direction: its first message is the response.
|
||||
phase: Phase,
|
||||
/// The publisher of a subscription has sent PUBLISH_DONE.
|
||||
publish_done: bool,
|
||||
/// REQUEST_UPDATEs sent by the requester side and by the responder side
|
||||
/// (only PUBLISH has both) that have no answer yet.
|
||||
updates_from_requester: u64,
|
||||
updates_from_responder: u64,
|
||||
/// MAX_REQUEST_UPDATES this endpoint advertised (bounds what it receives) and
|
||||
/// the peer advertised (bounds what it sends); 0 is no limit.
|
||||
my_limit: u64,
|
||||
peer_limit: u64,
|
||||
goaway_received: bool,
|
||||
namespaces: HashSet<TrackNamespace>,
|
||||
}
|
||||
|
||||
impl RequestStream {
|
||||
/// The stream of a request, as `me` sees it. `my_limit` and `peer_limit` are
|
||||
/// the two endpoints' MAX_REQUEST_UPDATES.
|
||||
pub fn new(kind: RequestKind, me: Side, my_limit: u64, peer_limit: u64) -> Self {
|
||||
RequestStream {
|
||||
kind,
|
||||
me,
|
||||
phase: Phase::AwaitingResponse,
|
||||
publish_done: false,
|
||||
updates_from_requester: 0,
|
||||
updates_from_responder: 0,
|
||||
my_limit,
|
||||
peer_limit,
|
||||
goaway_received: false,
|
||||
namespaces: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream the peer opened with `first`, which must be a request (§6.3:
|
||||
/// PROTOCOL_VIOLATION otherwise).
|
||||
pub fn opened_by_peer(first: &ControlMessage, my_limit: u64, peer_limit: u64) -> WireResult<Self> {
|
||||
let kind = first.opens_request().ok_or(WireError::UnexpectedMessage("a request stream that does not begin with a request"))?;
|
||||
Ok(RequestStream::new(kind, Side::Responder, my_limit, peer_limit))
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> RequestKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Whether the request was accepted (its first response was not REQUEST_ERROR).
|
||||
pub fn accepted(&self) -> bool {
|
||||
self.phase == Phase::Accepted
|
||||
}
|
||||
|
||||
/// The publisher of the subscription this stream carries.
|
||||
fn publisher(&self) -> Option<Side> {
|
||||
match self.kind {
|
||||
RequestKind::Subscribe => Some(Side::Responder),
|
||||
RequestKind::Publish => Some(Side::Requester),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a message this endpoint is about to send; refused where the peer
|
||||
/// would refuse it.
|
||||
pub fn on_send(&mut self, m: &ControlMessage) -> WireResult<()> {
|
||||
let me = self.me;
|
||||
self.admit(me, m, self.peer_limit)
|
||||
}
|
||||
|
||||
/// Admit a message received from the peer.
|
||||
pub fn on_receive(&mut self, m: &ControlMessage) -> WireResult<()> {
|
||||
let from = match self.me {
|
||||
Side::Requester => Side::Responder,
|
||||
Side::Responder => Side::Requester,
|
||||
};
|
||||
if let ControlMessage::Goaway(_) = m {
|
||||
if self.goaway_received {
|
||||
return Err(WireError::UnexpectedMessage("a second GOAWAY on one request stream"));
|
||||
}
|
||||
self.goaway_received = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.admit(from, m, self.my_limit)
|
||||
}
|
||||
|
||||
fn admit(&mut self, from: Side, m: &ControlMessage, limit: u64) -> WireResult<()> {
|
||||
use ControlMessage as M;
|
||||
if let M::Goaway(_) = m {
|
||||
return Ok(());
|
||||
}
|
||||
if m.opens_request().is_some() {
|
||||
return Err(WireError::UnexpectedMessage("a request inside another request's stream"));
|
||||
}
|
||||
if matches!(m, M::Setup(_)) {
|
||||
return Err(WireError::UnexpectedMessage("SETUP on a request stream"));
|
||||
}
|
||||
let publisher = self.publisher();
|
||||
match m {
|
||||
M::RequestUpdate(_) => {
|
||||
let allowed = self.kind != RequestKind::TrackStatus
|
||||
&& (from == Side::Requester || self.kind == RequestKind::Publish);
|
||||
if !allowed {
|
||||
return Err(WireError::UnexpectedMessage("REQUEST_UPDATE from an endpoint that may not update this request"));
|
||||
}
|
||||
if from == Side::Responder && self.phase != Phase::Accepted {
|
||||
return Err(WireError::UnexpectedMessage("a subscriber's REQUEST_UPDATE before its PUBLISH_OK"));
|
||||
}
|
||||
let n = if from == Side::Requester { &mut self.updates_from_requester } else { &mut self.updates_from_responder };
|
||||
if limit != 0 && *n >= limit {
|
||||
return Err(WireError::Session { code: SessionError::TOO_MANY_REQUEST_UPDATES, reason: "a REQUEST_UPDATE past MAX_REQUEST_UPDATES" });
|
||||
}
|
||||
*n += 1;
|
||||
return Ok(());
|
||||
}
|
||||
M::RequestOk(_) | M::RequestError(_) | M::SubscribeOk(_) | M::FetchOk(_) if from == Side::Responder && self.phase == Phase::AwaitingResponse => {
|
||||
let ok = match (self.kind, m) {
|
||||
(_, M::RequestError(_)) => false,
|
||||
(RequestKind::Subscribe, M::SubscribeOk(_)) | (RequestKind::Fetch, M::FetchOk(_)) => true,
|
||||
(RequestKind::Subscribe | RequestKind::Fetch, _) => {
|
||||
return Err(WireError::UnexpectedMessage("the first response is not SUBSCRIBE_OK, FETCH_OK or REQUEST_ERROR"))
|
||||
}
|
||||
(_, M::RequestOk(_)) => true,
|
||||
_ => return Err(WireError::UnexpectedMessage("the first response is not REQUEST_OK or REQUEST_ERROR")),
|
||||
};
|
||||
self.phase = if ok && self.kind != RequestKind::TrackStatus { Phase::Accepted } else { Phase::Done };
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if from == Side::Responder && self.phase == Phase::AwaitingResponse {
|
||||
// §3.6, §4.1, §4.2: the response is the first message the responder sends.
|
||||
// PUBLISH's subscriber is its responder; nothing it sends may come first.
|
||||
return Err(WireError::UnexpectedMessage("a message before the response on a request stream"));
|
||||
}
|
||||
if from == Side::Responder && self.phase == Phase::Done {
|
||||
return Err(WireError::UnexpectedMessage("a message after the request ended"));
|
||||
}
|
||||
match m {
|
||||
M::RequestOk(_) | M::RequestError(_) => {
|
||||
// An answer to an update the other side sent.
|
||||
let n = if from == Side::Responder { &mut self.updates_from_requester } else { &mut self.updates_from_responder };
|
||||
if *n == 0 {
|
||||
return Err(WireError::UnexpectedMessage("REQUEST_OK or REQUEST_ERROR with no update outstanding"));
|
||||
}
|
||||
*n -= 1;
|
||||
Ok(())
|
||||
}
|
||||
M::PublishDone(_) | M::PublishStateNotify(_) => {
|
||||
if publisher != Some(from) {
|
||||
return Err(WireError::UnexpectedMessage("PUBLISH_DONE or PUBLISH_STATE_NOTIFY from an endpoint that is not the subscription's publisher"));
|
||||
}
|
||||
if self.publish_done {
|
||||
return Err(WireError::UnexpectedMessage("a message after PUBLISH_DONE"));
|
||||
}
|
||||
if matches!(m, M::PublishDone(_)) {
|
||||
self.publish_done = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
M::Namespace(ns) | M::NamespaceDone(ns) => {
|
||||
if self.kind != RequestKind::SubscribeNamespace || from != Side::Responder {
|
||||
return Err(WireError::UnexpectedMessage("NAMESPACE or NAMESPACE_DONE outside a SUBSCRIBE_NAMESPACE response"));
|
||||
}
|
||||
if matches!(m, M::Namespace(_)) {
|
||||
self.namespaces.insert(ns.clone());
|
||||
} else if !self.namespaces.remove(ns) {
|
||||
return Err(WireError::UnexpectedMessage("NAMESPACE_DONE before its NAMESPACE"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
M::PublishSkipped(_) => {
|
||||
if self.kind != RequestKind::SubscribeTracks || from != Side::Responder {
|
||||
return Err(WireError::UnexpectedMessage("PUBLISH_SKIPPED outside a SUBSCRIBE_TRACKS response"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
M::SubscribeOk(_) | M::FetchOk(_) => Err(WireError::UnexpectedMessage("a second SUBSCRIBE_OK or FETCH_OK")),
|
||||
_ => Err(WireError::UnexpectedMessage("a message this request stream does not carry")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::draft22::control::{NamespaceRequest, RequestOk, RequestUpdate, SubscribeOk};
|
||||
use crate::draft22::kvp::Properties;
|
||||
use crate::draft22::params::Parameters;
|
||||
|
||||
#[test]
|
||||
fn request_ids() {
|
||||
let mut c = RequestIds::new(Perspective::Client);
|
||||
let mut s = RequestIds::new(Perspective::Server);
|
||||
assert_eq!((c.next(), c.next(), s.next(), s.next()), (Some(0), Some(2), Some(1), Some(3)));
|
||||
let mut peer = PeerRequestIds::new(Perspective::Client);
|
||||
peer.accept(2).unwrap();
|
||||
peer.accept(0).unwrap();
|
||||
assert!(matches!(peer.accept(1), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
|
||||
assert!(matches!(peer.accept(0), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
|
||||
assert!(matches!(peer.accept(2), Err(WireError::Session { code: SessionError::INVALID_REQUEST_ID, .. })));
|
||||
peer.accept(4).unwrap();
|
||||
let mut top = RequestIds { next: Some(u64::MAX - 1) };
|
||||
assert_eq!((top.next(), top.next()), (Some(u64::MAX - 1), None));
|
||||
}
|
||||
|
||||
fn ok() -> ControlMessage {
|
||||
ControlMessage::RequestOk(RequestOk { params: Parameters::new(), track_properties: Properties::new() })
|
||||
}
|
||||
fn upd() -> ControlMessage {
|
||||
ControlMessage::RequestUpdate(RequestUpdate { request_id: 2, params: Parameters::new() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subscription_stream() {
|
||||
// The subscriber's view: it may update once at a time (the peer's limit is 1).
|
||||
let mut st = RequestStream::new(RequestKind::Subscribe, Side::Requester, 0, 1);
|
||||
st.on_send(&upd()).unwrap();
|
||||
assert!(matches!(st.on_send(&upd()), Err(WireError::Session { code: SessionError::TOO_MANY_REQUEST_UPDATES, .. })));
|
||||
assert!(st.on_receive(&ok()).is_err(), "REQUEST_OK cannot be the first response to SUBSCRIBE");
|
||||
st.on_receive(&ControlMessage::SubscribeOk(SubscribeOk { track_alias: 1, params: Parameters::new(), track_properties: Properties::new() })).unwrap();
|
||||
st.on_receive(&ok()).unwrap();
|
||||
st.on_send(&upd()).unwrap();
|
||||
assert!(st.on_receive(&ControlMessage::Namespace(TrackNamespace::from_strs(["a"]))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_namespace_subscription_stream() {
|
||||
let first = ControlMessage::SubscribeNamespace(NamespaceRequest { request_id: 1, prefix: TrackNamespace::from_strs(["a"]), params: Parameters::new() });
|
||||
let mut st = RequestStream::opened_by_peer(&first, 0, 0).unwrap();
|
||||
let ns = TrackNamespace::from_strs(["b"]);
|
||||
assert!(st.on_send(&ControlMessage::Namespace(ns.clone())).is_err(), "NAMESPACE before the response");
|
||||
st.on_send(&ok()).unwrap();
|
||||
assert!(st.on_send(&ControlMessage::NamespaceDone(ns.clone())).is_err(), "NAMESPACE_DONE before NAMESPACE");
|
||||
st.on_send(&ControlMessage::Namespace(ns.clone())).unwrap();
|
||||
st.on_send(&ControlMessage::NamespaceDone(ns)).unwrap();
|
||||
assert!(RequestStream::opened_by_peer(&ok(), 0, 0).is_err());
|
||||
}
|
||||
}
|
||||
116
joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs
Normal file
116
joule-code/joule-code-rs/crates/joule-moq/src/draft22/token.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! The Authorization Token structure (§8.9), carried by the AUTHORIZATION TOKEN
|
||||
//! Setup Option (§9.1.4) and Message Parameter (§9.20.2).
|
||||
//!
|
||||
//! The codec reads the structure; the alias cache it implies (registration,
|
||||
//! MAX_AUTH_TOKEN_CACHE_SIZE, retirement) is the session's.
|
||||
|
||||
use super::error::{WireError, WireResult};
|
||||
use super::io::{put_varint, Reader};
|
||||
|
||||
/// Alias Types (§8.9, Table 12).
|
||||
pub mod alias {
|
||||
pub const DELETE: u64 = 0x0;
|
||||
pub const REGISTER: u64 = 0x1;
|
||||
pub const USE_ALIAS: u64 = 0x2;
|
||||
pub const USE_VALUE: u64 = 0x3;
|
||||
}
|
||||
|
||||
/// One Token structure.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Token {
|
||||
/// Retire `alias` and its value.
|
||||
Delete { alias: u64 },
|
||||
/// Register `alias` for `token_type` and `value`.
|
||||
Register { alias: u64, token_type: u64, value: Vec<u8> },
|
||||
/// Use the value registered under `alias`.
|
||||
UseAlias { alias: u64 },
|
||||
/// Use `value` as given.
|
||||
UseValue { token_type: u64, value: Vec<u8> },
|
||||
}
|
||||
|
||||
impl Token {
|
||||
pub fn alias_type(&self) -> u64 {
|
||||
match self {
|
||||
Token::Delete { .. } => alias::DELETE,
|
||||
Token::Register { .. } => alias::REGISTER,
|
||||
Token::UseAlias { .. } => alias::USE_ALIAS,
|
||||
Token::UseValue { .. } => alias::USE_VALUE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structure's bytes (the value of the option or parameter carrying it).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
put_varint(&mut out, self.alias_type());
|
||||
match self {
|
||||
Token::Delete { alias } | Token::UseAlias { alias } => put_varint(&mut out, *alias),
|
||||
Token::Register { alias, token_type, value } => {
|
||||
put_varint(&mut out, *alias);
|
||||
put_varint(&mut out, *token_type);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
Token::UseValue { token_type, value } => {
|
||||
put_varint(&mut out, *token_type);
|
||||
out.extend_from_slice(value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a whole byte string as one Token. The Token Value runs to the end of
|
||||
/// the enclosing field. Anything that does not read is
|
||||
/// [`WireError::MalformedToken`] (§8.9: KEY_VALUE_FORMATTING_ERROR in -22).
|
||||
pub fn decode(b: &[u8]) -> WireResult<Token> {
|
||||
let bad = |reason| WireError::MalformedToken { reason };
|
||||
let mut r = Reader::new(b);
|
||||
let t = r.varint("token alias type").map_err(|_| bad("truncated"))?;
|
||||
let v = |r: &mut Reader<'_>| r.varint("token field").map_err(|_| bad("truncated"));
|
||||
let tok = match t {
|
||||
alias::DELETE => Token::Delete { alias: v(&mut r)? },
|
||||
alias::REGISTER => {
|
||||
let alias = v(&mut r)?;
|
||||
let token_type = v(&mut r)?;
|
||||
Token::Register { alias, token_type, value: r.rest().to_vec() }
|
||||
}
|
||||
alias::USE_ALIAS => Token::UseAlias { alias: v(&mut r)? },
|
||||
alias::USE_VALUE => {
|
||||
let token_type = v(&mut r)?;
|
||||
Token::UseValue { token_type, value: r.rest().to_vec() }
|
||||
}
|
||||
_ => return Err(bad("unknown alias type")),
|
||||
};
|
||||
if !r.is_empty() {
|
||||
return Err(bad("bytes after an alias"));
|
||||
}
|
||||
Ok(tok)
|
||||
}
|
||||
|
||||
/// The token type and value it states itself (`None` for an alias
|
||||
/// reference), for §8.9's uniqueness rule within a message.
|
||||
pub fn explicit(&self) -> Option<(u64, &[u8])> {
|
||||
match self {
|
||||
Token::Register { token_type, value, .. } | Token::UseValue { token_type, value } => Some((*token_type, value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_alias_type_round_trips() {
|
||||
for t in [
|
||||
Token::Delete { alias: 9 },
|
||||
Token::Register { alias: 1, token_type: 0, value: b"jwt".to_vec() },
|
||||
Token::UseAlias { alias: 300 },
|
||||
Token::UseValue { token_type: 0x9d, value: vec![] },
|
||||
] {
|
||||
assert_eq!(Token::decode(&t.to_bytes()).unwrap(), t);
|
||||
}
|
||||
assert!(matches!(Token::decode(&[0x04]), Err(WireError::MalformedToken { .. })));
|
||||
assert!(matches!(Token::decode(&[0x00, 0x01, 0x02]), Err(WireError::MalformedToken { .. })));
|
||||
assert!(matches!(Token::decode(&[]), Err(WireError::MalformedToken { .. })));
|
||||
}
|
||||
}
|
||||
161
joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs
Normal file
161
joule-code/joule-code-rs/crates/joule-moq/src/draft22/varint.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! The variable-length integer of `draft-ietf-moq-transport-22` §8.1.
|
||||
//!
|
||||
//! The number of leading 1 bits of the first byte gives the length of the
|
||||
//! encoding, from 1 byte (`0xxxxxxx`, 7 usable bits) to 9 bytes (`11111111`
|
||||
//! followed by the 64-bit value). Every 64-bit value is representable, and an
|
||||
//! encoding need not be the shortest: `0x25`, `0x8025` and `0xc00025` all read
|
||||
//! as 37. This is not QUIC's variable-length integer (RFC 9000 §16), which the
|
||||
//! drafts before -17 used.
|
||||
|
||||
use super::error::WireError;
|
||||
|
||||
/// The largest value the encoding carries: every `u64`.
|
||||
pub const VARINT_MAX: u64 = u64::MAX;
|
||||
|
||||
/// The number of bytes the shortest encoding of `v` takes.
|
||||
pub const fn encoded_len(v: u64) -> usize {
|
||||
// Usable bits for lengths 1..=8 are 7 * len; the 9-byte form carries 64.
|
||||
let mut len = 1;
|
||||
while len < 9 {
|
||||
if len == 8 {
|
||||
// 8 bytes: 56 usable bits.
|
||||
if v < (1u64 << 56) {
|
||||
return 8;
|
||||
}
|
||||
return 9;
|
||||
}
|
||||
if v < (1u64 << (7 * len)) {
|
||||
return len;
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
9
|
||||
}
|
||||
|
||||
/// Append the shortest encoding of `v` to `out`.
|
||||
pub fn put(out: &mut Vec<u8>, v: u64) {
|
||||
put_with_len(out, v, encoded_len(v)).expect("the shortest length always fits");
|
||||
}
|
||||
|
||||
/// Append `v` encoded in exactly `len` bytes (1 to 9), which may be longer than
|
||||
/// the shortest form (§8.1: "any encoding length that can represent the value
|
||||
/// is valid"). Refused when `v` does not fit in `len` bytes.
|
||||
pub fn put_with_len(out: &mut Vec<u8>, v: u64, len: usize) -> Result<(), WireError> {
|
||||
if !(1..=9).contains(&len) || len < encoded_len(v) {
|
||||
return Err(WireError::Unencodable { what: "varint length", reason: "the value does not fit the requested length" });
|
||||
}
|
||||
if len == 9 {
|
||||
out.push(0xff);
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
return Ok(());
|
||||
}
|
||||
// `len - 1` leading ones, then a zero, then the value's top bits.
|
||||
let prefix: u8 = if len == 1 { 0 } else { !(0xffu8 >> (len - 1)) };
|
||||
let bytes = v.to_be_bytes();
|
||||
let tail = &bytes[8 - len..];
|
||||
out.push(prefix | tail[0]);
|
||||
out.extend_from_slice(&tail[1..]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The length an encoding takes, read from its first byte.
|
||||
pub const fn len_from_first(first: u8) -> usize {
|
||||
let ones = first.leading_ones() as usize;
|
||||
if ones >= 8 {
|
||||
9
|
||||
} else {
|
||||
ones + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one varint from the front of `buf`: `(value, bytes read)`, or
|
||||
/// [`WireError::Truncated`] when `buf` ends inside it.
|
||||
pub fn get(buf: &[u8], what: &'static str) -> Result<(u64, usize), WireError> {
|
||||
let first = *buf.first().ok_or(WireError::Truncated { what })?;
|
||||
let len = len_from_first(first);
|
||||
if buf.len() < len {
|
||||
return Err(WireError::Truncated { what });
|
||||
}
|
||||
if len == 9 {
|
||||
let mut b = [0u8; 8];
|
||||
b.copy_from_slice(&buf[1..9]);
|
||||
return Ok((u64::from_be_bytes(b), 9));
|
||||
}
|
||||
// `len` leading bits are the prefix (with its terminating zero); an
|
||||
// 8-byte form has no value bits in its first byte. Shift in u32 so len 8
|
||||
// cannot overflow the shift.
|
||||
let mask: u8 = if len == 1 { 0x7f } else { (0xffu32 >> len) as u8 };
|
||||
let mut v = (first & mask) as u64;
|
||||
for &b in &buf[1..len] {
|
||||
v = (v << 8) | b as u64;
|
||||
}
|
||||
Ok((v, len))
|
||||
}
|
||||
|
||||
/// Encode `v` as a fresh byte vector (shortest form).
|
||||
pub fn to_vec(v: u64) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(9);
|
||||
put(&mut out, v);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Table 4 of the draft, both ways where the example is the shortest form.
|
||||
#[test]
|
||||
fn the_drafts_examples() {
|
||||
let table: [(&str, u64, bool); 8] = [
|
||||
("25", 37, true),
|
||||
("8025", 37, false),
|
||||
("bbbd", 15_293, true),
|
||||
("ed7f3e7d", 226_442_877, true),
|
||||
("faa1a0e403d8", 2_893_212_287_960, true),
|
||||
("fc8998abc66bc0", 151_288_809_941_952, true),
|
||||
("fefa318fa8e3ca11", 70_423_237_261_249_041, true),
|
||||
("ffffffffffffffffff", u64::MAX, true),
|
||||
];
|
||||
for (h, v, shortest) in table {
|
||||
let b = hex(h);
|
||||
assert_eq!(get(&b, "t").unwrap(), (v, b.len()), "{h}");
|
||||
if shortest {
|
||||
assert_eq!(to_vec(v), b, "{h}");
|
||||
}
|
||||
let mut long = Vec::new();
|
||||
put_with_len(&mut long, v, b.len()).unwrap();
|
||||
assert_eq!(long, b, "{h} at its own length");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_round_trip_at_every_length_that_fits() {
|
||||
let mut vals = vec![0u64, u64::MAX];
|
||||
for bits in [7u32, 14, 21, 28, 35, 42, 49, 56, 63] {
|
||||
vals.push((1u64 << bits) - 1);
|
||||
vals.push(1u64 << bits);
|
||||
}
|
||||
for v in vals {
|
||||
for len in encoded_len(v)..=9 {
|
||||
let mut b = Vec::new();
|
||||
put_with_len(&mut b, v, len).unwrap();
|
||||
assert_eq!(b.len(), len);
|
||||
assert_eq!(get(&b, "t").unwrap(), (v, len), "{v} in {len}");
|
||||
}
|
||||
if encoded_len(v) > 1 {
|
||||
assert!(put_with_len(&mut Vec::new(), v, encoded_len(v) - 1).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_buffer_is_truncated() {
|
||||
assert_eq!(get(&[], "x"), Err(WireError::Truncated { what: "x" }));
|
||||
assert_eq!(get(&[0xc0, 0x00], "x"), Err(WireError::Truncated { what: "x" }));
|
||||
assert_eq!(get(&[0xff; 8], "x"), Err(WireError::Truncated { what: "x" }));
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,9 @@ pub enum MoqError {
|
|||
|
||||
#[error("jwp: {0}")]
|
||||
Jwp(String),
|
||||
|
||||
#[error("moqt wire: {0}")]
|
||||
Wire(#[from] crate::draft22::WireError),
|
||||
}
|
||||
|
||||
impl From<jwp::JwpError> for MoqError {
|
||||
|
|
|
|||
|
|
@ -1,41 +1,62 @@
|
|||
//! **joule-moq** — MoQT (Media over QUIC Transport) wire protocol + JWP receipt
|
||||
//! **joule-moq** — MoqT (Media over QUIC Transport) wire protocol + JWP receipt
|
||||
//! extension for the CommunicationOS broadcast surface.
|
||||
//!
|
||||
//! MoQT is the IETF-track pub/sub transport for live and broadcast media over
|
||||
//! QUIC. Tracks fan out through relays to subscribers; objects within a track
|
||||
//! are independently addressable, cacheable, and replayable. This crate
|
||||
//! delivers the wire protocol *plus* a first-class JWP receipt extension so
|
||||
//! every object carries verifiable provenance, joule cost, and (optionally)
|
||||
//! MLS-wrapped end-to-end ciphertext.
|
||||
//! MoqT is the IETF-track pub/sub transport for live and broadcast media over
|
||||
//! QUIC and WebTransport. Tracks fan out through relays to subscribers; objects
|
||||
//! within a track are independently addressable, cacheable, and replayable.
|
||||
//! This crate delivers the wire protocol *plus* a first-class JWP receipt
|
||||
//! extension so every object carries verifiable provenance, joule cost, and
|
||||
//! (optionally) MLS-wrapped end-to-end ciphertext.
|
||||
//!
|
||||
//! ## The draft it implements
|
||||
//!
|
||||
//! `draft-ietf-moq-transport-22` (1 October 2026), negotiated by the ALPN
|
||||
//! `moqt-22` ([`draft::Draft`]). [`MOQT_DRAFT_VERSION`] is its conventional
|
||||
//! code, `0xff000016`. Drafts do not interoperate with each other, so a session
|
||||
//! speaks one from its first byte; the codec of -22 is a module of its own
|
||||
//! ([`draft22`]), and another draft would be a sibling of it.
|
||||
//!
|
||||
//! ## What this crate ships
|
||||
//!
|
||||
//! - Wire codec for the MoQT control + data planes ([`wire`], [`messages`])
|
||||
//! - The -22 wire codec ([`draft22`]): the variable-length integer, Key-Value
|
||||
//! Pairs, Properties and Setup Options, the Authorization Token, every
|
||||
//! control message and its parameters, Request IDs and request-stream rules,
|
||||
//! subgroup streams, datagrams, fetch streams and padding, the status and
|
||||
//! error codes; bounded decoders with a typed error for every refusal.
|
||||
//! Re-exported for the control plane by [`messages`] and [`wire`].
|
||||
//! - The carriage of this crate's objects and receipts on -22 objects
|
||||
//! ([`carriage`]): object properties, the `WAI_RECEIPT` property of WAI
|
||||
//! `extensions/moq-streaming-format.md` §5, and receipts tracks (§4.1 there).
|
||||
//! - Track / Group / Object identifiers and metadata ([`track`])
|
||||
//! - Session state machine for publisher / subscriber / relay roles ([`session`])
|
||||
//! - Endpoint trait surface ([`endpoint`])
|
||||
//! - **JWP receipt extension** — batched per-group signing with Merkle inclusion
|
||||
//! proofs per object, identity-bound publication, joule attribution ([`receipt`])
|
||||
//! - In-memory mock for testing without QUIC ([`mock`]), whose relay also forwards
|
||||
//! staged tracks on a caller-driven clock: per-hop delivery timeouts, capping by
|
||||
//! - In-memory mock for testing without QUIC ([`mock`]), whose objects, receipts
|
||||
//! and requests pass through the -22 codec; its relay also forwards staged
|
||||
//! tracks on a caller-driven clock: per-hop delivery timeouts, capping by
|
||||
//! subscription, and the receipts of dropped objects forwarded all the same
|
||||
//! - Staged tracks, one per layer of a WAI stage set ([`staged`]), and receipt chains
|
||||
//! kept per publisher key and track ([`ReceiptChains`])
|
||||
//! - A native QUIC transport behind the `quic` feature (`transport_quic`): -22
|
||||
//! control streams, request streams, subgroup and fetch streams and datagrams
|
||||
//! over quinn.
|
||||
//!
|
||||
//! ## What this crate intentionally does not ship (yet)
|
||||
//!
|
||||
//! - A production QUIC implementation (gated behind the `quic` feature; v0.1
|
||||
//! exposes the trait surface only).
|
||||
//! - Browser-native subscriber (will be a separate WASM crate).
|
||||
//! - WebTransport (MOQT over HTTP/3), a relay with priority fan-out, and a
|
||||
//! browser client: the next slice builds them on this codec.
|
||||
//! - The authorization token cache (§8.9: aliases, MAX_AUTH_TOKEN_CACHE_SIZE)
|
||||
//! and the subscription filters' evaluation at a publisher: the codec reads
|
||||
//! and writes them, and the session does not yet act on them.
|
||||
//! - The relay binary (`joule-moq-relay`) and publisher SDK (`joule-moq-publisher`)
|
||||
//! live in their own crates and depend on this one.
|
||||
//!
|
||||
//! ## Standards alignment
|
||||
//!
|
||||
//! Targets `draft-ietf-moq-transport` (working group draft, evolving). Wire
|
||||
//! constants and message types are versioned per the draft revision implemented
|
||||
//! by [`MOQT_DRAFT_VERSION`]. The JWP receipt extension is a non-normative
|
||||
//! addition we plan to submit as `draft-charlot-moq-jwp-extensions`.
|
||||
//! Implements `draft-ietf-moq-transport-22`, a working-group draft that is
|
||||
//! still changing. The JWP receipt extension is a non-normative addition we plan
|
||||
//! to submit as `draft-charlot-moq-jwp-extensions`.
|
||||
//!
|
||||
//! ## Receipt-first design
|
||||
//!
|
||||
|
|
@ -43,6 +64,9 @@
|
|||
//! by construction. There is no "raw" object path that bypasses receipt
|
||||
//! generation.
|
||||
|
||||
pub mod carriage;
|
||||
pub mod draft;
|
||||
pub mod draft22;
|
||||
pub mod endpoint;
|
||||
pub mod error;
|
||||
pub mod messages;
|
||||
|
|
@ -57,13 +81,14 @@ pub mod wire;
|
|||
#[cfg(feature = "quic")]
|
||||
pub mod transport_quic;
|
||||
|
||||
pub use carriage::{ReceiptBundle, WaiReceiptProperty};
|
||||
pub use draft::Draft;
|
||||
pub use endpoint::{
|
||||
MoqEndpoint, Publisher, Relay, Subscriber, SubscriptionHandle,
|
||||
};
|
||||
pub use error::{MoqError, MoqResult};
|
||||
pub use messages::{
|
||||
AnnounceMessage, ControlMessage, ObjectHeader, SetupMessage,
|
||||
SubscribeMessage, SubscribeOk,
|
||||
ControlMessage, NamespaceRequest, ObjectHeader, Setup, SubscribeOk, TrackRequest,
|
||||
};
|
||||
pub use receipt::{
|
||||
compose_group_class, Admission, Anchoring, BoundRefused, ChainEvent, ChainRefusal, GroupReceipt, HoldBound, ObjectReceipt,
|
||||
|
|
@ -81,15 +106,16 @@ pub use wai_dispatch::{
|
|||
};
|
||||
pub use wire::{decode_varint, encode_varint, WireCodec};
|
||||
|
||||
/// MoQT draft revision targeted by this crate.
|
||||
/// MoqT draft revision targeted by this crate: `draft-ietf-moq-transport-22`,
|
||||
/// as the conventional code `0xff000000 + 22`.
|
||||
///
|
||||
/// The IETF MoQ working group is iterating; bumping this constant signals
|
||||
/// a wire-incompatible version of the protocol.
|
||||
pub const MOQT_DRAFT_VERSION: u32 = 0xff00_0008; // matches draft-08-style version
|
||||
/// Bumping this constant signals a wire-incompatible version of the protocol.
|
||||
/// -22 carries no version in SETUP; the ALPN `moqt-22` selects it.
|
||||
pub const MOQT_DRAFT_VERSION: u32 = draft22::VERSION;
|
||||
|
||||
/// JWP receipt extension version.
|
||||
///
|
||||
/// Independent of the MoQT draft version. Bumped when the receipt header
|
||||
/// Independent of the MoqT draft version. Bumped when the receipt header
|
||||
/// schema changes. 2: the labelled object leaf and the labelled group payload
|
||||
/// (optional members; a receipt without them keeps its bytes).
|
||||
pub const JWP_MOQ_EXTENSION_VERSION: u8 = 2;
|
||||
|
|
|
|||
|
|
@ -1,120 +1,27 @@
|
|||
//! MoQT control-plane messages and object headers.
|
||||
//! MoqT control messages and the crate's object header.
|
||||
//!
|
||||
//! These types follow `draft-ietf-moq-transport`. Type tags and field
|
||||
//! ordering will track the IETF draft; bumping [`crate::MOQT_DRAFT_VERSION`]
|
||||
//! signals a wire-incompatible change.
|
||||
//! The control plane is `draft-ietf-moq-transport-22`'s (§9), re-exported from
|
||||
//! [`crate::draft22`]: SETUP on each endpoint's unidirectional control stream,
|
||||
//! every request on a bidirectional stream of its own, answered there. Bumping
|
||||
//! [`crate::MOQT_DRAFT_VERSION`] signals a wire-incompatible change; a draft is
|
||||
//! chosen per session by [`crate::draft::Draft`].
|
||||
//!
|
||||
//! The control plane runs on a single bidirectional QUIC stream (the
|
||||
//! "control stream"); object delivery uses unidirectional QUIC streams,
|
||||
//! one per group (or per object, depending on forwarding preference).
|
||||
//! [`ObjectHeader`] is the crate's draft-neutral view of one delivered object;
|
||||
//! [`crate::carriage`] maps it onto a -22 subgroup object.
|
||||
|
||||
use crate::error::{MoqError, MoqResult};
|
||||
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias, TrackName, TrackNamespace};
|
||||
use crate::wire::{decode_bytes, decode_varint, encode_bytes, encode_varint};
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Control-plane message-type tags (from draft) ──
|
||||
// Track the latest draft; these are placeholder mappings consistent with
|
||||
// the structure of the published spec.
|
||||
pub const MSG_CLIENT_SETUP: u64 = 0x40;
|
||||
pub const MSG_SERVER_SETUP: u64 = 0x41;
|
||||
pub const MSG_ANNOUNCE: u64 = 0x06;
|
||||
pub const MSG_ANNOUNCE_OK: u64 = 0x07;
|
||||
pub const MSG_ANNOUNCE_ERROR: u64 = 0x08;
|
||||
pub const MSG_UNANNOUNCE: u64 = 0x09;
|
||||
pub const MSG_SUBSCRIBE: u64 = 0x03;
|
||||
pub const MSG_SUBSCRIBE_OK: u64 = 0x04;
|
||||
pub const MSG_SUBSCRIBE_ERROR: u64 = 0x05;
|
||||
pub const MSG_UNSUBSCRIBE: u64 = 0x0a;
|
||||
pub const MSG_GOAWAY: u64 = 0x10;
|
||||
|
||||
/// Top-level control-plane message envelope.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum ControlMessage {
|
||||
Setup(SetupMessage),
|
||||
Announce(AnnounceMessage),
|
||||
AnnounceOk { namespace: TrackNamespace },
|
||||
AnnounceError { namespace: TrackNamespace, code: u64, reason: String },
|
||||
Unannounce { namespace: TrackNamespace },
|
||||
Subscribe(SubscribeMessage),
|
||||
SubscribeOk(SubscribeOk),
|
||||
SubscribeError { subscribe_id: u64, code: u64, reason: String },
|
||||
Unsubscribe { subscribe_id: u64 },
|
||||
Goaway { uri: Option<String> },
|
||||
}
|
||||
|
||||
/// SETUP — exchanged at session establishment to negotiate version and roles.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SetupMessage {
|
||||
/// MoQT draft version offered (CLIENT_SETUP) or selected (SERVER_SETUP).
|
||||
pub versions: Vec<u32>,
|
||||
/// Role bits: 0x01 = publisher, 0x02 = subscriber, 0x03 = pubsub.
|
||||
pub role: u8,
|
||||
/// Whether the peer requests JWP receipt extension support.
|
||||
pub jwp_receipts: bool,
|
||||
/// Optional human-readable peer identity hint (production binds via
|
||||
/// TrustOS-issued credentials, not this field).
|
||||
pub peer_label: Option<String>,
|
||||
}
|
||||
|
||||
/// ANNOUNCE — publisher tells the relay (or peer) that it has tracks
|
||||
/// available under the given namespace.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AnnounceMessage {
|
||||
pub namespace: TrackNamespace,
|
||||
/// Optional bound publisher credential (TrustOS DID / VC envelope).
|
||||
/// Empty in the bare protocol; the JWP-MoQ extension populates this.
|
||||
pub publisher_credential: Vec<u8>,
|
||||
}
|
||||
|
||||
/// SUBSCRIBE — request delivery of a track.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SubscribeMessage {
|
||||
pub subscribe_id: u64,
|
||||
pub track_alias: TrackAlias,
|
||||
pub namespace: TrackNamespace,
|
||||
pub track_name: TrackName,
|
||||
/// Filter for which objects to deliver: latest group, range, etc.
|
||||
pub filter: SubscribeFilter,
|
||||
/// Whether the subscriber requires receipt verification end-to-end.
|
||||
pub require_receipts: bool,
|
||||
/// Matryoshka fidelity cap. If `Some(n)`, the relay forwards only
|
||||
/// objects whose [`ObjectHeader::mrl_layer`] is `<= n`. `None` means
|
||||
/// the subscriber accepts every layer the publisher emits. Always
|
||||
/// `None` for non-MRL tracks; older subscribers that don't include
|
||||
/// this field on the wire are decoded as `None`.
|
||||
///
|
||||
/// **Superseded for tracks of packaging `wai`** (WAI staged-delivery §13,
|
||||
/// moq-streaming-format §4.1): a WAI stage set carries each layer on a track of
|
||||
/// its own, and a subscriber caps its stage by the layer tracks it subscribes to; a
|
||||
/// relay reads no envelope and no header to cap layers. The field keeps its wire
|
||||
/// form for the tracks that still use it.
|
||||
#[serde(default)]
|
||||
pub max_mrl_layer: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum SubscribeFilter {
|
||||
LatestGroup,
|
||||
LatestObject,
|
||||
AbsoluteStart { group: GroupId, object: ObjectId },
|
||||
AbsoluteRange { start: (GroupId, ObjectId), end: (GroupId, ObjectId) },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SubscribeOk {
|
||||
pub subscribe_id: u64,
|
||||
/// Server expiry hint, milliseconds from now (0 = no expiry).
|
||||
pub expires_ms: u64,
|
||||
/// The largest (group, object) currently published, when known.
|
||||
pub largest: Option<(GroupId, ObjectId)>,
|
||||
}
|
||||
pub use crate::draft22::control::{
|
||||
msg, ControlMessage, Fetch, FetchOk, Goaway, NamespaceRequest, Publish, PublishDone, PublishSkipped, Redirect, RequestError, RequestOk,
|
||||
RequestUpdate, Setup, Subscribe, SubscribeOk, TrackRequest, TrackStatus,
|
||||
};
|
||||
pub use crate::draft22::params::{GroupOrder, LocationFilter, Parameter, Parameters};
|
||||
|
||||
/// Header preceding each delivered object on a data stream.
|
||||
///
|
||||
/// In the JWP-MoQ extension, an additional [`ObjectReceipt`] header is
|
||||
/// appended after this struct (see [`crate::receipt`]).
|
||||
/// On the wire it is a -22 subgroup object ([`crate::carriage`]); the object's
|
||||
/// [`crate::receipt::ObjectReceipt`] rides in its properties.
|
||||
///
|
||||
/// **MRL (Matryoshka Representation Learning) extension.** When a track
|
||||
/// carries layered codec output — Encodec residual codebooks, layered
|
||||
|
|
@ -122,9 +29,11 @@ pub struct SubscribeOk {
|
|||
/// group corresponds to one "doll layer". The [`ObjectHeader::mrl_layer`]
|
||||
/// field indicates the layer index (0 = coarsest base, monotonically
|
||||
/// increasing fidelity); [`ObjectHeader::mrl_total_layers`] is the total
|
||||
/// number of layers the publisher emitted in this group. Subscribers can
|
||||
/// declare a `max_mrl_layer` policy and the relay drops higher layers
|
||||
/// without disturbing the per-object signed receipts.
|
||||
/// number of layers the publisher emitted in this group. A subscriber caps
|
||||
/// the layers it receives with an OBJECT_PROPERTY_FILTER on the `MRL_LAYER`
|
||||
/// property ([`mrl_cap_filter`]; -22 §3.3.2, which needs the peer's
|
||||
/// MAX_FILTER_RANGES above 0), and the relay drops higher layers without
|
||||
/// disturbing the per-object signed receipts.
|
||||
///
|
||||
/// For tracks that do not carry MRL-structured content, both fields
|
||||
/// default to `0`. Existing receipts and wire-format consumers are
|
||||
|
|
@ -211,374 +120,75 @@ mod object_header_wai_tests {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Wire encoding ──
|
||||
|
||||
impl ControlMessage {
|
||||
pub fn encode(&self) -> MoqResult<Bytes> {
|
||||
let mut buf = BytesMut::new();
|
||||
let (tag, body) = self.encode_body()?;
|
||||
encode_varint(tag, &mut buf)?;
|
||||
encode_varint(body.len() as u64, &mut buf)?;
|
||||
buf.extend_from_slice(&body);
|
||||
Ok(buf.freeze())
|
||||
}
|
||||
|
||||
fn encode_body(&self) -> MoqResult<(u64, Bytes)> {
|
||||
let mut body = BytesMut::new();
|
||||
let tag = match self {
|
||||
ControlMessage::Setup(s) => {
|
||||
encode_varint(s.versions.len() as u64, &mut body)?;
|
||||
for v in &s.versions {
|
||||
encode_varint(*v as u64, &mut body)?;
|
||||
}
|
||||
body.extend_from_slice(&[s.role]);
|
||||
body.extend_from_slice(&[s.jwp_receipts as u8]);
|
||||
let label = s.peer_label.as_deref().unwrap_or("");
|
||||
encode_bytes(label.as_bytes(), &mut body)?;
|
||||
MSG_CLIENT_SETUP
|
||||
}
|
||||
ControlMessage::Announce(a) => {
|
||||
encode_namespace(&a.namespace, &mut body)?;
|
||||
encode_bytes(&a.publisher_credential, &mut body)?;
|
||||
MSG_ANNOUNCE
|
||||
}
|
||||
ControlMessage::AnnounceOk { namespace } => {
|
||||
encode_namespace(namespace, &mut body)?;
|
||||
MSG_ANNOUNCE_OK
|
||||
}
|
||||
ControlMessage::AnnounceError { namespace, code, reason } => {
|
||||
encode_namespace(namespace, &mut body)?;
|
||||
encode_varint(*code, &mut body)?;
|
||||
encode_bytes(reason.as_bytes(), &mut body)?;
|
||||
MSG_ANNOUNCE_ERROR
|
||||
}
|
||||
ControlMessage::Unannounce { namespace } => {
|
||||
encode_namespace(namespace, &mut body)?;
|
||||
MSG_UNANNOUNCE
|
||||
}
|
||||
ControlMessage::Subscribe(s) => {
|
||||
encode_varint(s.subscribe_id, &mut body)?;
|
||||
encode_varint(s.track_alias.0, &mut body)?;
|
||||
encode_namespace(&s.namespace, &mut body)?;
|
||||
encode_bytes(&s.track_name.0, &mut body)?;
|
||||
encode_filter(&s.filter, &mut body)?;
|
||||
body.extend_from_slice(&[s.require_receipts as u8]);
|
||||
// Trailing optional MRL cap: [present:u8][value:u8 if present].
|
||||
// Older receivers ignore unread trailing bytes; newer
|
||||
// receivers default to None when these bytes are absent.
|
||||
match s.max_mrl_layer {
|
||||
Some(cap) => body.extend_from_slice(&[1u8, cap]),
|
||||
None => body.extend_from_slice(&[0u8]),
|
||||
}
|
||||
MSG_SUBSCRIBE
|
||||
}
|
||||
ControlMessage::SubscribeOk(ok) => {
|
||||
encode_varint(ok.subscribe_id, &mut body)?;
|
||||
encode_varint(ok.expires_ms, &mut body)?;
|
||||
match ok.largest {
|
||||
Some((g, o)) => {
|
||||
body.extend_from_slice(&[1u8]);
|
||||
encode_varint(g.0, &mut body)?;
|
||||
encode_varint(o.0, &mut body)?;
|
||||
}
|
||||
None => body.extend_from_slice(&[0u8]),
|
||||
}
|
||||
MSG_SUBSCRIBE_OK
|
||||
}
|
||||
ControlMessage::SubscribeError { subscribe_id, code, reason } => {
|
||||
encode_varint(*subscribe_id, &mut body)?;
|
||||
encode_varint(*code, &mut body)?;
|
||||
encode_bytes(reason.as_bytes(), &mut body)?;
|
||||
MSG_SUBSCRIBE_ERROR
|
||||
}
|
||||
ControlMessage::Unsubscribe { subscribe_id } => {
|
||||
encode_varint(*subscribe_id, &mut body)?;
|
||||
MSG_UNSUBSCRIBE
|
||||
}
|
||||
ControlMessage::Goaway { uri } => {
|
||||
let s = uri.as_deref().unwrap_or("");
|
||||
encode_bytes(s.as_bytes(), &mut body)?;
|
||||
MSG_GOAWAY
|
||||
}
|
||||
};
|
||||
Ok((tag, body.freeze()))
|
||||
}
|
||||
|
||||
pub fn decode(src: &mut Bytes) -> MoqResult<Self> {
|
||||
let tag = decode_varint(src)?;
|
||||
let len = decode_varint(src)? as usize;
|
||||
if src.len() < len {
|
||||
return Err(MoqError::WireDecode(format!(
|
||||
"control: need {} bytes, have {}",
|
||||
len,
|
||||
src.len()
|
||||
)));
|
||||
}
|
||||
let mut body = src.split_to(len);
|
||||
match tag {
|
||||
MSG_CLIENT_SETUP | MSG_SERVER_SETUP => {
|
||||
let n = decode_varint(&mut body)? as usize;
|
||||
let mut versions = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
versions.push(decode_varint(&mut body)? as u32);
|
||||
}
|
||||
let role = body.split_to(1)[0];
|
||||
let jwp_receipts = body.split_to(1)[0] != 0;
|
||||
let label_bytes = decode_bytes(&mut body)?;
|
||||
let peer_label = if label_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&label_bytes).into_owned())
|
||||
};
|
||||
Ok(ControlMessage::Setup(SetupMessage {
|
||||
versions,
|
||||
role,
|
||||
jwp_receipts,
|
||||
peer_label,
|
||||
}))
|
||||
}
|
||||
MSG_ANNOUNCE => {
|
||||
let namespace = decode_namespace(&mut body)?;
|
||||
let publisher_credential = decode_bytes(&mut body)?.to_vec();
|
||||
Ok(ControlMessage::Announce(AnnounceMessage {
|
||||
namespace,
|
||||
publisher_credential,
|
||||
}))
|
||||
}
|
||||
MSG_ANNOUNCE_OK => {
|
||||
let namespace = decode_namespace(&mut body)?;
|
||||
Ok(ControlMessage::AnnounceOk { namespace })
|
||||
}
|
||||
MSG_SUBSCRIBE => {
|
||||
let subscribe_id = decode_varint(&mut body)?;
|
||||
let track_alias = TrackAlias(decode_varint(&mut body)?);
|
||||
let namespace = decode_namespace(&mut body)?;
|
||||
let track_name = TrackName(decode_bytes(&mut body)?.to_vec());
|
||||
let filter = decode_filter(&mut body)?;
|
||||
let require_receipts = body.split_to(1)[0] != 0;
|
||||
// Optional trailing MRL cap. Older subscribers omit these
|
||||
// bytes; treat absence as None.
|
||||
let max_mrl_layer = if body.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let present = body.split_to(1)[0] != 0;
|
||||
if present {
|
||||
if body.is_empty() {
|
||||
return Err(MoqError::WireDecode(
|
||||
"max_mrl_layer flagged present but value byte missing".into(),
|
||||
));
|
||||
}
|
||||
Some(body.split_to(1)[0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
Ok(ControlMessage::Subscribe(SubscribeMessage {
|
||||
subscribe_id,
|
||||
track_alias,
|
||||
namespace,
|
||||
track_name,
|
||||
filter,
|
||||
require_receipts,
|
||||
max_mrl_layer,
|
||||
}))
|
||||
}
|
||||
MSG_SUBSCRIBE_OK => {
|
||||
let subscribe_id = decode_varint(&mut body)?;
|
||||
let expires_ms = decode_varint(&mut body)?;
|
||||
let has_largest = body.split_to(1)[0] != 0;
|
||||
let largest = if has_largest {
|
||||
Some((
|
||||
GroupId(decode_varint(&mut body)?),
|
||||
ObjectId(decode_varint(&mut body)?),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ControlMessage::SubscribeOk(SubscribeOk {
|
||||
subscribe_id,
|
||||
expires_ms,
|
||||
largest,
|
||||
}))
|
||||
}
|
||||
MSG_UNSUBSCRIBE => {
|
||||
let subscribe_id = decode_varint(&mut body)?;
|
||||
Ok(ControlMessage::Unsubscribe { subscribe_id })
|
||||
}
|
||||
other => Err(MoqError::UnexpectedMessage(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_namespace(ns: &TrackNamespace, dst: &mut BytesMut) -> MoqResult<()> {
|
||||
encode_varint(ns.0.len() as u64, dst)?;
|
||||
for seg in &ns.0 {
|
||||
encode_bytes(seg, dst)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_namespace(src: &mut Bytes) -> MoqResult<TrackNamespace> {
|
||||
let n = decode_varint(src)? as usize;
|
||||
let mut segs = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
segs.push(decode_bytes(src)?.to_vec());
|
||||
}
|
||||
Ok(TrackNamespace(segs))
|
||||
}
|
||||
|
||||
fn encode_filter(f: &SubscribeFilter, dst: &mut BytesMut) -> MoqResult<()> {
|
||||
match f {
|
||||
SubscribeFilter::LatestGroup => {
|
||||
dst.extend_from_slice(&[0u8]);
|
||||
}
|
||||
SubscribeFilter::LatestObject => {
|
||||
dst.extend_from_slice(&[1u8]);
|
||||
}
|
||||
SubscribeFilter::AbsoluteStart { group, object } => {
|
||||
dst.extend_from_slice(&[2u8]);
|
||||
encode_varint(group.0, dst)?;
|
||||
encode_varint(object.0, dst)?;
|
||||
}
|
||||
SubscribeFilter::AbsoluteRange { start, end } => {
|
||||
dst.extend_from_slice(&[3u8]);
|
||||
encode_varint(start.0.0, dst)?;
|
||||
encode_varint(start.1.0, dst)?;
|
||||
encode_varint(end.0.0, dst)?;
|
||||
encode_varint(end.1.0, dst)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_filter(src: &mut Bytes) -> MoqResult<SubscribeFilter> {
|
||||
let tag = src.split_to(1)[0];
|
||||
match tag {
|
||||
0 => Ok(SubscribeFilter::LatestGroup),
|
||||
1 => Ok(SubscribeFilter::LatestObject),
|
||||
2 => Ok(SubscribeFilter::AbsoluteStart {
|
||||
group: GroupId(decode_varint(src)?),
|
||||
object: ObjectId(decode_varint(src)?),
|
||||
/// A SUBSCRIBE parameter that caps a layered track at `cap`: an
|
||||
/// OBJECT_PROPERTY_FILTER (-22 §9.20.13) passing objects whose `MRL_LAYER`
|
||||
/// property ([`crate::carriage::prop::MRL_LAYER`]) is 0 to `cap`. This is the
|
||||
/// -22 form of the `max_mrl_layer` field that SUBSCRIBE carried before. An
|
||||
/// object without the property does not pass a property filter, so a publisher
|
||||
/// of a capped track states the layer on every object.
|
||||
pub fn mrl_cap_filter(cap: u8) -> Parameter {
|
||||
Parameter::RangeFilter {
|
||||
ty: crate::draft22::params::param::OBJECT_PROPERTY_FILTER,
|
||||
filter: Some(crate::draft22::params::RangeFilter {
|
||||
set_id: 0,
|
||||
property_type: Some(crate::carriage::prop::MRL_LAYER),
|
||||
ranges: vec![crate::draft22::params::Range { start: 0, end: Some(cap as u64) }],
|
||||
}),
|
||||
3 => Ok(SubscribeFilter::AbsoluteRange {
|
||||
start: (GroupId(decode_varint(src)?), ObjectId(decode_varint(src)?)),
|
||||
end: (GroupId(decode_varint(src)?), ObjectId(decode_varint(src)?)),
|
||||
}),
|
||||
other => Err(MoqError::WireDecode(format!("filter tag {}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer cap a SUBSCRIBE's parameters set by [`mrl_cap_filter`], if any.
|
||||
pub fn mrl_cap(params: &Parameters) -> Option<u8> {
|
||||
params.range_filters().find_map(|(ty, f)| {
|
||||
let f = f?;
|
||||
let ok = ty == crate::draft22::params::param::OBJECT_PROPERTY_FILTER
|
||||
&& f.property_type == Some(crate::carriage::prop::MRL_LAYER)
|
||||
&& f.ranges.len() == 1
|
||||
&& f.ranges[0].start == 0;
|
||||
if ok {
|
||||
f.ranges[0].end.and_then(|e| u8::try_from(e).ok())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod d22_tests {
|
||||
use super::*;
|
||||
use crate::track::{TrackName, TrackNamespace};
|
||||
|
||||
/// The draft-08-style tests of SUBSCRIBE, rewritten for -22: a SUBSCRIBE
|
||||
/// round-trips, and a layer cap rides it as a property filter.
|
||||
#[test]
|
||||
fn subscribe_roundtrip() {
|
||||
let msg = ControlMessage::Subscribe(SubscribeMessage {
|
||||
subscribe_id: 7,
|
||||
track_alias: TrackAlias(42),
|
||||
namespace: TrackNamespace::from_strs(["tv", "transaction.science"]),
|
||||
track_name: TrackName::from_str("live"),
|
||||
filter: SubscribeFilter::LatestGroup,
|
||||
require_receipts: true,
|
||||
max_mrl_layer: None,
|
||||
});
|
||||
let mut bytes = msg.encode().unwrap();
|
||||
let decoded = ControlMessage::decode(&mut bytes).unwrap();
|
||||
match decoded {
|
||||
ControlMessage::Subscribe(s) => {
|
||||
assert_eq!(s.subscribe_id, 7);
|
||||
assert_eq!(s.track_alias.0, 42);
|
||||
assert!(s.require_receipts);
|
||||
assert_eq!(s.max_mrl_layer, None);
|
||||
fn subscribe_round_trips_with_and_without_a_layer_cap() {
|
||||
for cap in [None, Some(2u8)] {
|
||||
let mut params = Parameters::new().with(Parameter::LocationFilter(LocationFilter::NextObject));
|
||||
if let Some(c) = cap {
|
||||
params.push(mrl_cap_filter(c));
|
||||
}
|
||||
let m = ControlMessage::Subscribe(TrackRequest {
|
||||
request_id: 6,
|
||||
namespace: TrackNamespace::from_strs(["tv", "transaction.science"]),
|
||||
name: TrackName::from_str("live"),
|
||||
params,
|
||||
});
|
||||
let back = ControlMessage::decode_exact(&m.encode().unwrap()).unwrap();
|
||||
assert_eq!(back, m);
|
||||
match back {
|
||||
ControlMessage::Subscribe(s) => assert_eq!(mrl_cap(&s.params), cap),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_roundtrip_with_mrl_cap() {
|
||||
let msg = ControlMessage::Subscribe(SubscribeMessage {
|
||||
subscribe_id: 99,
|
||||
track_alias: TrackAlias(7),
|
||||
namespace: TrackNamespace::from_strs(["calls", "demo"]),
|
||||
track_name: TrackName::from_str("audio"),
|
||||
filter: SubscribeFilter::LatestGroup,
|
||||
require_receipts: true,
|
||||
max_mrl_layer: Some(2),
|
||||
fn publish_namespace_round_trips() {
|
||||
let m = ControlMessage::PublishNamespace(NamespaceRequest {
|
||||
request_id: 1,
|
||||
prefix: TrackNamespace::from_strs(["tv", "transaction.science", "ticker"]),
|
||||
params: Parameters::new(),
|
||||
});
|
||||
let mut bytes = msg.encode().unwrap();
|
||||
let decoded = ControlMessage::decode(&mut bytes).unwrap();
|
||||
match decoded {
|
||||
ControlMessage::Subscribe(s) => {
|
||||
assert_eq!(s.max_mrl_layer, Some(2));
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An older encoder that omits the trailing MRL bytes should still
|
||||
/// decode (max_mrl_layer is `None`). We simulate by trimming the
|
||||
/// last byte (the `0u8` "no-cap" sentinel) before decode.
|
||||
#[test]
|
||||
fn subscribe_decode_handles_legacy_wire_without_mrl_bytes() {
|
||||
let msg = ControlMessage::Subscribe(SubscribeMessage {
|
||||
subscribe_id: 1,
|
||||
track_alias: TrackAlias(0),
|
||||
namespace: TrackNamespace::from_strs(["x"]),
|
||||
track_name: TrackName::from_str("y"),
|
||||
filter: SubscribeFilter::LatestGroup,
|
||||
require_receipts: false,
|
||||
max_mrl_layer: None,
|
||||
});
|
||||
// Encode normally, then strip the trailing byte (the "no-cap"
|
||||
// sentinel) to simulate a legacy peer that didn't include it.
|
||||
let full = msg.encode().unwrap();
|
||||
let mut buf = bytes::BytesMut::from(&full[..]);
|
||||
// Decrement the inner length varint by one byte and drop the last
|
||||
// byte. Easier: just rebuild with a hand-trimmed payload that
|
||||
// decodes the same: re-decode requires knowing the framing — so
|
||||
// verify legacy compatibility via the final-byte-empty path.
|
||||
// Drop the trailing 0u8 sentinel and adjust the length.
|
||||
let len_decrement = 1usize;
|
||||
let mut hand = bytes::BytesMut::new();
|
||||
// The encoded message is: [tag varint][len varint][body bytes...].
|
||||
// We re-emit it with body shorter by 1 and len reduced by 1.
|
||||
let mut src = full.clone();
|
||||
let tag = decode_varint(&mut src).unwrap();
|
||||
let inner_len = decode_varint(&mut src).unwrap() as usize;
|
||||
let body = src.split_to(inner_len);
|
||||
let new_body = body.slice(..body.len() - len_decrement);
|
||||
encode_varint(tag, &mut hand).unwrap();
|
||||
encode_varint(new_body.len() as u64, &mut hand).unwrap();
|
||||
hand.extend_from_slice(&new_body);
|
||||
let mut hand_bytes = hand.freeze();
|
||||
let decoded = ControlMessage::decode(&mut hand_bytes).unwrap();
|
||||
match decoded {
|
||||
ControlMessage::Subscribe(s) => {
|
||||
assert_eq!(s.max_mrl_layer, None);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
// sanity: ensure we used buf (silence dead-code warning).
|
||||
let _ = buf.split();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announce_roundtrip() {
|
||||
let ns = TrackNamespace::from_strs(["tv", "transaction.science", "ticker"]);
|
||||
let msg = ControlMessage::Announce(AnnounceMessage {
|
||||
namespace: ns.clone(),
|
||||
publisher_credential: vec![1, 2, 3, 4],
|
||||
});
|
||||
let mut bytes = msg.encode().unwrap();
|
||||
let decoded = ControlMessage::decode(&mut bytes).unwrap();
|
||||
match decoded {
|
||||
ControlMessage::Announce(a) => {
|
||||
assert_eq!(a.namespace, ns);
|
||||
assert_eq!(a.publisher_credential, vec![1, 2, 3, 4]);
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
assert_eq!(ControlMessage::decode_exact(&m.encode().unwrap()).unwrap(), m);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@
|
|||
//! transport is a `tokio::sync::mpsc` channel; both ends share a process.
|
||||
//!
|
||||
//! Receipts produced here are *real* — Ed25519-signed group roots, BLAKE3
|
||||
//! Merkle trees, end-to-end verifiable. Only the transport is mocked.
|
||||
//! Merkle trees, end-to-end verifiable. Only the transport is mocked: what
|
||||
//! crosses it is `draft-ietf-moq-transport-22`. Each object is encoded as a
|
||||
//! subgroup stream and decoded on the far side ([`crate::carriage`]), its
|
||||
//! receipt riding in its properties; a receipts-track bundle is encoded as the
|
||||
//! one object of its group; SUBSCRIBE, SUBSCRIBE_OK, PUBLISH_NAMESPACE and
|
||||
//! REQUEST_OK are encoded and decoded with Request IDs from the right end's
|
||||
//! space.
|
||||
//!
|
||||
//! [`MockRelay`] also forwards staged tracks (WAI `extensions/moq-streaming-format.md`
|
||||
//! §4.1) on a clock its caller drives: a delivery timeout per track or per object,
|
||||
|
|
@ -16,6 +22,10 @@
|
|||
//! for receipts whatever became of the objects they cover. [`RelayStats`] counts what it
|
||||
//! forwarded and dropped. Nothing sleeps: time is what the caller says it is.
|
||||
|
||||
use crate::carriage::{decode_object_stream, encode_object_stream, ObjectExtras, WaiReceiptProperty};
|
||||
pub use crate::carriage::ReceiptBundle;
|
||||
use crate::draft22::{ControlMessage, NamespaceRequest, Parameters, Perspective, RequestIds, RequestOk, SubscribeOk, TrackRequest};
|
||||
use crate::draft22::kvp::Properties;
|
||||
use crate::endpoint::{
|
||||
DeliveredObject, GroupHandle, MoqEndpoint, Publisher, Relay, Subscriber, SubscriptionHandle,
|
||||
};
|
||||
|
|
@ -56,6 +66,19 @@ impl MockFabric {
|
|||
TrackAlias(self.next_alias.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// Carry one object across the mock wire: as a -22 subgroup stream of one
|
||||
/// object, decoded on the far side.
|
||||
fn deliver_wire(&self, key: &TrackKey, wire: &[u8]) -> MoqResult<()> {
|
||||
let (header, payload, extras) = decode_object_stream(wire)?;
|
||||
if let (Some(w), Some(r)) = (&extras.wai_receipt, &extras.receipt)
|
||||
&& (w.content_hash != r.content_hash || w.joules_micro != r.joules_micro)
|
||||
{
|
||||
return Err(MoqError::ReceiptVerification("the WAI_RECEIPT property disagrees with the object receipt".into()));
|
||||
}
|
||||
self.deliver(key, DeliveredObject { header, payload, receipt: extras.receipt });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deliver(&self, key: &TrackKey, obj: DeliveredObject) {
|
||||
let mut routes = self.routes.lock().unwrap();
|
||||
if let Some(senders) = routes.get_mut(key) {
|
||||
|
|
@ -84,6 +107,8 @@ pub struct MockPublisher {
|
|||
signing_key: SigningKey,
|
||||
announced: Mutex<Vec<TrackNamespace>>,
|
||||
track_aliases: Mutex<HashMap<TrackKey, TrackAlias>>,
|
||||
/// The publisher is the server end of its mock session: odd Request IDs.
|
||||
request_ids: Mutex<RequestIds>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
|
|
@ -99,6 +124,7 @@ impl MockPublisher {
|
|||
signing_key,
|
||||
announced: Mutex::new(Vec::new()),
|
||||
track_aliases: Mutex::new(HashMap::new()),
|
||||
request_ids: Mutex::new(RequestIds::new(Perspective::Server)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +152,13 @@ impl MoqEndpoint for MockPublisher {
|
|||
#[async_trait]
|
||||
impl Publisher for MockPublisher {
|
||||
async fn announce(&mut self, namespace: TrackNamespace) -> MoqResult<()> {
|
||||
self.announced.lock().unwrap().push(namespace);
|
||||
let request_id = self.request_ids.lock().unwrap().next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))?;
|
||||
let sent = ControlMessage::PublishNamespace(NamespaceRequest { request_id, prefix: namespace, params: Parameters::new() });
|
||||
let ControlMessage::PublishNamespace(got) = across(&sent)? else {
|
||||
return Err(MoqError::WireDecode("PUBLISH_NAMESPACE crossed the wire as another message".into()));
|
||||
};
|
||||
across(&ControlMessage::RequestOk(RequestOk { params: Parameters::new(), track_properties: Properties::new() }))?;
|
||||
self.announced.lock().unwrap().push(got.prefix);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -227,14 +259,9 @@ impl GroupHandle for MockGroupHandle {
|
|||
merkle_proof: proof,
|
||||
energy_class: None,
|
||||
};
|
||||
fabric.deliver(
|
||||
&key,
|
||||
DeliveredObject {
|
||||
header,
|
||||
payload,
|
||||
receipt: Some(receipt),
|
||||
},
|
||||
);
|
||||
let wai_receipt = WaiReceiptProperty::from_receipts(&receipt, &group_receipt);
|
||||
let extras = ObjectExtras { receipt: Some(receipt), wai_receipt, delivery_timeout_ms: None };
|
||||
fabric.deliver_wire(&key, &encode_object_stream(&header, &payload, &extras)?)?;
|
||||
}
|
||||
Ok(group_receipt)
|
||||
}
|
||||
|
|
@ -244,18 +271,24 @@ impl GroupHandle for MockGroupHandle {
|
|||
|
||||
pub struct MockSubscriber {
|
||||
pub fabric: Arc<MockFabric>,
|
||||
next_subscribe_id: AtomicU64,
|
||||
/// The subscriber is the client end of its mock session: even Request IDs.
|
||||
request_ids: Mutex<RequestIds>,
|
||||
}
|
||||
|
||||
impl MockSubscriber {
|
||||
pub fn new(fabric: Arc<MockFabric>) -> Self {
|
||||
Self {
|
||||
fabric,
|
||||
next_subscribe_id: AtomicU64::new(0),
|
||||
request_ids: Mutex::new(RequestIds::new(Perspective::Client)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One control message across the mock wire: encoded, then decoded.
|
||||
fn across(m: &ControlMessage) -> MoqResult<ControlMessage> {
|
||||
Ok(ControlMessage::decode_exact(&m.encode()?)?)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MoqEndpoint for MockSubscriber {
|
||||
fn version(&self) -> Option<u32> {
|
||||
|
|
@ -281,17 +314,26 @@ impl Subscriber for MockSubscriber {
|
|||
_require_receipts: bool,
|
||||
) -> MoqResult<SubscriptionHandle> {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let track_alias = self.fabric.allocate_alias();
|
||||
let request_id = self.request_ids.lock().unwrap().next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))?;
|
||||
let sent = ControlMessage::Subscribe(TrackRequest { request_id, namespace, name: track_name, params: Parameters::new() });
|
||||
let ControlMessage::Subscribe(got) = across(&sent)? else {
|
||||
return Err(MoqError::WireDecode("SUBSCRIBE crossed the wire as another message".into()));
|
||||
};
|
||||
// The fabric, as the publisher, picks the Track Alias and answers.
|
||||
let answer = ControlMessage::SubscribeOk(SubscribeOk { track_alias: self.fabric.allocate_alias().0, params: Parameters::new(), track_properties: Properties::new() });
|
||||
let ControlMessage::SubscribeOk(ok) = across(&answer)? else {
|
||||
return Err(MoqError::WireDecode("SUBSCRIBE_OK crossed the wire as another message".into()));
|
||||
};
|
||||
self.fabric.register(
|
||||
TrackKey {
|
||||
namespace,
|
||||
track_name,
|
||||
namespace: got.namespace,
|
||||
track_name: got.name,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
Ok(SubscriptionHandle {
|
||||
subscribe_id: self.next_subscribe_id.fetch_add(1, Ordering::SeqCst),
|
||||
track_alias,
|
||||
subscribe_id: got.request_id,
|
||||
track_alias: TrackAlias(ok.track_alias),
|
||||
rx,
|
||||
})
|
||||
}
|
||||
|
|
@ -339,14 +381,6 @@ pub struct RelayStats {
|
|||
pub receipts_forwarded_for_dropped: u64,
|
||||
}
|
||||
|
||||
/// One group's receipts, as one object of a receipts track carries them: the group
|
||||
/// receipt and the object receipts of one group of the receipts track's media track.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ReceiptBundle {
|
||||
pub group: GroupReceipt,
|
||||
pub objects: Vec<ObjectReceipt>,
|
||||
}
|
||||
|
||||
/// Something the relay sent, and when, on the caller's clock.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Sent<T> {
|
||||
|
|
@ -483,6 +517,13 @@ impl MockRelay {
|
|||
object_timeout_ms: Option<u64>,
|
||||
) -> MoqResult<()> {
|
||||
let key = TrackKey { namespace: namespace.clone(), track_name: track_name.clone() };
|
||||
// The object crosses the upstream hop as a -22 subgroup stream; its own
|
||||
// delivery timeout rides as its OBJECT_DELIVERY_TIMEOUT property (§10.2),
|
||||
// which the relay reads back.
|
||||
let extras = ObjectExtras { receipt: object.receipt.clone(), wai_receipt: None, delivery_timeout_ms: object_timeout_ms };
|
||||
let (header, payload, extras) = decode_object_stream(&encode_object_stream(&object.header, &object.payload, &extras)?)?;
|
||||
let object = DeliveredObject { header, payload, receipt: extras.receipt };
|
||||
let object_timeout_ms = extras.delivery_timeout_ms;
|
||||
let send_ms = received_ms
|
||||
.checked_add(queue_ms)
|
||||
.ok_or_else(|| MoqError::InvalidSessionState(format!("a send time past the clock ({received_ms} + {queue_ms})")))?;
|
||||
|
|
@ -505,6 +546,15 @@ impl MockRelay {
|
|||
/// nothing in it to forward it: it goes at once to every subscriber of that receipts
|
||||
/// track, whatever became of the objects it covers.
|
||||
pub fn ingest_receipts(&self, namespace: &TrackNamespace, receipts_track: &TrackName, bundle: ReceiptBundle, received_ms: u64) {
|
||||
// The bundle crosses the upstream hop as the one object of its group on the
|
||||
// receipts track. A bundle the codec cannot carry is not forwarded.
|
||||
let bundle = match bundle.encode_stream(bundle.group.track_alias.0, bundle.group.group_id.0, 0).and_then(|b| ReceiptBundle::decode_stream(&b)) {
|
||||
Ok((_, b)) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!("receipt bundle not forwarded: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let rkey = TrackKey { namespace: namespace.clone(), track_name: receipts_track.clone() };
|
||||
let mut st = self.staged.lock().unwrap();
|
||||
// For counting only: the media track whose receipts track this is, and the keys of
|
||||
|
|
|
|||
|
|
@ -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(()),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
//! Session state machine for a MoQT endpoint.
|
||||
//! Session state machine for a MoqT endpoint.
|
||||
//!
|
||||
//! A session is a single QUIC connection between two MoQT peers. Both ends
|
||||
//! traverse the same lifecycle: `Init → Handshaking → Established → Closing
|
||||
//! → Closed`. The role (publisher / subscriber / pubsub / relay) is
|
||||
//! negotiated during handshake.
|
||||
//! A session is a single QUIC connection (or WebTransport session) between two
|
||||
//! MoqT peers. Both ends traverse the same lifecycle: `Init → Handshaking →
|
||||
//! Established → Closing → Closed`.
|
||||
//!
|
||||
//! Under `draft-ietf-moq-transport-22` the draft is fixed before the first MOQT
|
||||
//! byte, by ALPN or WebTransport protocol ([`MoqSession::select_alpn`]); each
|
||||
//! endpoint then opens a control stream with a SETUP of its own, carrying Setup
|
||||
//! Options and no version and no role. Extensions are declared there: this crate
|
||||
//! declares the JWP receipt extension by
|
||||
//! [`crate::carriage::SETUP_OPTION_JWP_RECEIPTS`]. The role
|
||||
//! ([`SessionRole`]) is this endpoint's own business; -22 does not exchange it.
|
||||
|
||||
use crate::carriage::SETUP_OPTION_JWP_RECEIPTS;
|
||||
use crate::draft::Draft;
|
||||
use crate::draft22::kvp::{opt, KeyValuePair, SetupOptions};
|
||||
use crate::draft22::{PeerRequestIds, Perspective, RequestIds, Setup, WireError};
|
||||
use crate::error::{MoqError, MoqResult};
|
||||
use crate::messages::SetupMessage;
|
||||
use crate::MOQT_DRAFT_VERSION;
|
||||
use crate::JWP_MOQ_EXTENSION_VERSION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -20,6 +30,8 @@ pub enum SessionRole {
|
|||
}
|
||||
|
||||
impl SessionRole {
|
||||
/// The role as the bits draft-08's SETUP carried. -22 exchanges no role;
|
||||
/// kept for callers that log or store it.
|
||||
pub fn role_bits(&self) -> u8 {
|
||||
match self {
|
||||
SessionRole::Publisher => 0x01,
|
||||
|
|
@ -39,20 +51,36 @@ pub enum SessionState {
|
|||
Closed,
|
||||
}
|
||||
|
||||
/// Cross-cutting state for a single MoQT session.
|
||||
/// Cross-cutting state for a single MoqT session.
|
||||
pub struct MoqSession {
|
||||
pub role: SessionRole,
|
||||
/// Whether this endpoint opened the transport session.
|
||||
pub perspective: Perspective,
|
||||
pub state: SessionState,
|
||||
/// The draft the ALPN selected.
|
||||
pub draft: Option<Draft>,
|
||||
/// That draft's 32-bit code, once the session is established.
|
||||
pub negotiated_version: Option<u32>,
|
||||
pub jwp_receipts_enabled: bool,
|
||||
/// Active subscriptions, keyed by subscribe_id.
|
||||
/// Active subscriptions, keyed by the Request ID of their SUBSCRIBE or PUBLISH.
|
||||
pub subscriptions: HashMap<u64, SubscriptionState>,
|
||||
/// Track alias → subscribe_id reverse index.
|
||||
/// Track alias → Request ID reverse index.
|
||||
pub alias_index: HashMap<u64, u64>,
|
||||
/// This endpoint's own Request IDs.
|
||||
pub request_ids: RequestIds,
|
||||
/// The peer's Request IDs seen so far.
|
||||
pub peer_request_ids: PeerRequestIds,
|
||||
/// The peer's Setup Options, once its SETUP arrived.
|
||||
pub peer_options: Option<SetupOptions>,
|
||||
/// MAX_REQUEST_UPDATES this endpoint declares (0: no limit).
|
||||
pub max_request_updates: u64,
|
||||
/// MAX_FILTER_RANGES this endpoint declares (0: the peer sends no Range Filter).
|
||||
pub max_filter_ranges: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SubscriptionState {
|
||||
/// The Request ID of the SUBSCRIBE (or PUBLISH) that made it.
|
||||
pub subscribe_id: u64,
|
||||
pub track_alias: u64,
|
||||
pub require_receipts: bool,
|
||||
|
|
@ -61,65 +89,106 @@ pub struct SubscriptionState {
|
|||
pub joules_consumed_micro: u64,
|
||||
}
|
||||
|
||||
/// The MOQT_IMPLEMENTATION this crate sends (§9.1.5).
|
||||
pub fn implementation_name() -> String {
|
||||
format!("joule-moq/{}", crate::VERSION)
|
||||
}
|
||||
|
||||
impl MoqSession {
|
||||
/// A session as the server end of its transport session.
|
||||
pub fn new(role: SessionRole) -> Self {
|
||||
Self::with_perspective(role, Perspective::Server)
|
||||
}
|
||||
|
||||
pub fn with_perspective(role: SessionRole, perspective: Perspective) -> Self {
|
||||
Self {
|
||||
role,
|
||||
perspective,
|
||||
state: SessionState::Init,
|
||||
draft: None,
|
||||
negotiated_version: None,
|
||||
jwp_receipts_enabled: false,
|
||||
subscriptions: HashMap::new(),
|
||||
alias_index: HashMap::new(),
|
||||
request_ids: RequestIds::new(perspective),
|
||||
peer_request_ids: PeerRequestIds::new(perspective.peer()),
|
||||
peer_options: None,
|
||||
max_request_updates: 0,
|
||||
max_filter_ranges: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce the SETUP message this endpoint will send first.
|
||||
pub fn build_setup(&self, request_jwp: bool, label: Option<String>) -> SetupMessage {
|
||||
SetupMessage {
|
||||
versions: vec![MOQT_DRAFT_VERSION],
|
||||
role: self.role.role_bits(),
|
||||
jwp_receipts: request_jwp,
|
||||
peer_label: label,
|
||||
}
|
||||
/// Choose the draft from the ALPNs a client offered (a server), or check the
|
||||
/// one a server selected (a client): refused when this crate speaks none of
|
||||
/// them.
|
||||
pub fn select_alpn<'a>(&mut self, offered: impl IntoIterator<Item = &'a [u8]>) -> MoqResult<Draft> {
|
||||
let d = Draft::select(offered).ok_or(MoqError::UnsupportedVersion(0))?;
|
||||
self.draft = Some(d);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Process a peer SETUP and transition to Handshaking → Established.
|
||||
pub fn accept_setup(&mut self, peer: &SetupMessage) -> MoqResult<SetupMessage> {
|
||||
if !peer.versions.contains(&MOQT_DRAFT_VERSION) {
|
||||
return Err(MoqError::UnsupportedVersion(
|
||||
peer.versions.first().copied().unwrap_or(0),
|
||||
));
|
||||
/// The SETUP this endpoint sends: its implementation, the limits it
|
||||
/// declares, and the JWP receipt extension when `request_jwp`.
|
||||
pub fn build_setup(&self, request_jwp: bool) -> Setup {
|
||||
let mut o = SetupOptions::new();
|
||||
o.push(KeyValuePair::bytes(opt::MOQT_IMPLEMENTATION, implementation_name().into_bytes()));
|
||||
if self.max_filter_ranges != 0 {
|
||||
o.push(KeyValuePair::varint(opt::MAX_FILTER_RANGES, self.max_filter_ranges));
|
||||
}
|
||||
if self.max_request_updates != 0 {
|
||||
o.push(KeyValuePair::varint(opt::MAX_REQUEST_UPDATES, self.max_request_updates));
|
||||
}
|
||||
if request_jwp {
|
||||
o.push(KeyValuePair::varint(SETUP_OPTION_JWP_RECEIPTS, JWP_MOQ_EXTENSION_VERSION as u64));
|
||||
}
|
||||
Setup { options: o }
|
||||
}
|
||||
|
||||
/// Whether a SETUP declares the JWP receipt extension at this crate's version.
|
||||
pub fn declares_jwp(setup: &Setup) -> bool {
|
||||
setup.options.extension(SETUP_OPTION_JWP_RECEIPTS).and_then(KeyValuePair::as_varint) == Some(JWP_MOQ_EXTENSION_VERSION as u64)
|
||||
}
|
||||
|
||||
/// Take the peer's SETUP: refused where its options break the draft for a
|
||||
/// peer at its end (§9.1.1, §9.1.2, §9.1.4). The JWP receipt extension is on
|
||||
/// when the peer declares it at this crate's version and `we_offer` it.
|
||||
pub fn on_peer_setup(&mut self, peer: &Setup, we_offer_jwp: bool) -> MoqResult<()> {
|
||||
peer.check_from(self.perspective.peer(), false)
|
||||
.map_err(|code| MoqError::Wire(WireError::Session { code, reason: "the peer's SETUP breaks a rule for its end" }))?;
|
||||
self.state = SessionState::Handshaking;
|
||||
self.negotiated_version = Some(MOQT_DRAFT_VERSION);
|
||||
self.jwp_receipts_enabled = peer.jwp_receipts;
|
||||
let response = self.build_setup(self.jwp_receipts_enabled, None);
|
||||
let d = *self.draft.get_or_insert(Draft::D22);
|
||||
self.negotiated_version = Some(d.version_code());
|
||||
self.jwp_receipts_enabled = we_offer_jwp && Self::declares_jwp(peer);
|
||||
self.peer_options = Some(peer.options.clone());
|
||||
self.state = SessionState::Established;
|
||||
Ok(response)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_subscription(
|
||||
&mut self,
|
||||
subscribe_id: u64,
|
||||
track_alias: u64,
|
||||
require_receipts: bool,
|
||||
) -> MoqResult<()> {
|
||||
/// Take the peer's SETUP and produce this endpoint's, offering the JWP
|
||||
/// extension exactly when the peer did.
|
||||
pub fn accept_setup(&mut self, peer: &Setup) -> MoqResult<Setup> {
|
||||
let offer = Self::declares_jwp(peer);
|
||||
self.on_peer_setup(peer, offer)?;
|
||||
Ok(self.build_setup(self.jwp_receipts_enabled))
|
||||
}
|
||||
|
||||
/// The next Request ID this endpoint gives a request.
|
||||
pub fn next_request_id(&mut self) -> MoqResult<u64> {
|
||||
self.request_ids.next().ok_or_else(|| MoqError::InvalidSessionState("Request IDs exhausted".into()))
|
||||
}
|
||||
|
||||
/// Admit a Request ID the peer sent (INVALID_REQUEST_ID otherwise).
|
||||
pub fn accept_peer_request_id(&mut self, id: u64) -> MoqResult<()> {
|
||||
Ok(self.peer_request_ids.accept(id)?)
|
||||
}
|
||||
|
||||
pub fn add_subscription(&mut self, subscribe_id: u64, track_alias: u64, require_receipts: bool) -> MoqResult<()> {
|
||||
if self.state != SessionState::Established {
|
||||
return Err(MoqError::InvalidSessionState(format!(
|
||||
"subscribe requires Established, was {:?}",
|
||||
self.state
|
||||
)));
|
||||
return Err(MoqError::InvalidSessionState(format!("subscribe requires Established, was {:?}", self.state)));
|
||||
}
|
||||
self.subscriptions.insert(
|
||||
subscribe_id,
|
||||
SubscriptionState {
|
||||
subscribe_id,
|
||||
track_alias,
|
||||
require_receipts,
|
||||
last_group: None,
|
||||
last_object: None,
|
||||
joules_consumed_micro: 0,
|
||||
},
|
||||
SubscriptionState { subscribe_id, track_alias, require_receipts, last_group: None, last_object: None, joules_consumed_micro: 0 },
|
||||
);
|
||||
self.alias_index.insert(track_alias, subscribe_id);
|
||||
Ok(())
|
||||
|
|
@ -131,21 +200,9 @@ impl MoqSession {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn record_object(
|
||||
&mut self,
|
||||
track_alias: u64,
|
||||
group_id: u64,
|
||||
object_id: u64,
|
||||
joules_micro: u64,
|
||||
) -> MoqResult<()> {
|
||||
let sub_id = *self
|
||||
.alias_index
|
||||
.get(&track_alias)
|
||||
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
|
||||
let sub = self
|
||||
.subscriptions
|
||||
.get_mut(&sub_id)
|
||||
.ok_or(MoqError::SubscriptionNotFound(track_alias))?;
|
||||
pub fn record_object(&mut self, track_alias: u64, group_id: u64, object_id: u64, joules_micro: u64) -> MoqResult<()> {
|
||||
let sub_id = *self.alias_index.get(&track_alias).ok_or(MoqError::SubscriptionNotFound(track_alias))?;
|
||||
let sub = self.subscriptions.get_mut(&sub_id).ok_or(MoqError::SubscriptionNotFound(track_alias))?;
|
||||
sub.last_group = Some(group_id);
|
||||
sub.last_object = Some(object_id);
|
||||
sub.joules_consumed_micro = sub.joules_consumed_micro.saturating_add(joules_micro);
|
||||
|
|
@ -163,32 +220,57 @@ impl MoqSession {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::draft22::codes::SessionError;
|
||||
use crate::draft22::Token;
|
||||
use crate::MOQT_DRAFT_VERSION;
|
||||
|
||||
/// The handshake: the client's SETUP declares the receipt extension, the
|
||||
/// server's answers it, and both sessions are established on -22.
|
||||
#[test]
|
||||
fn handshake_round_trip() {
|
||||
let mut srv = MoqSession::new(SessionRole::PubSub);
|
||||
let client_setup = SetupMessage {
|
||||
versions: vec![MOQT_DRAFT_VERSION],
|
||||
role: 0x02,
|
||||
jwp_receipts: true,
|
||||
peer_label: Some("client".into()),
|
||||
};
|
||||
let client = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
|
||||
let client_setup = client.build_setup(true);
|
||||
let resp = srv.accept_setup(&client_setup).unwrap();
|
||||
assert_eq!(srv.state, SessionState::Established);
|
||||
assert!(srv.jwp_receipts_enabled);
|
||||
assert!(resp.jwp_receipts);
|
||||
assert!(MoqSession::declares_jwp(&resp));
|
||||
assert_eq!(srv.negotiated_version, Some(MOQT_DRAFT_VERSION));
|
||||
assert_eq!(resp.options.implementation(), Some(implementation_name().as_bytes()));
|
||||
// Through the codec and back.
|
||||
let wire = crate::draft22::ControlMessage::Setup(resp.clone()).encode().unwrap();
|
||||
assert_eq!(crate::draft22::ControlMessage::decode_exact(&wire).unwrap(), crate::draft22::ControlMessage::Setup(resp));
|
||||
}
|
||||
|
||||
/// A version is refused at the ALPN, before any SETUP: -22 carries none in
|
||||
/// SETUP. And a SETUP that breaks a rule for its sender's end is refused.
|
||||
#[test]
|
||||
fn rejects_unsupported_version() {
|
||||
let mut srv = MoqSession::new(SessionRole::Publisher);
|
||||
let bad_setup = SetupMessage {
|
||||
versions: vec![0x1234_5678],
|
||||
role: 0x02,
|
||||
jwp_receipts: false,
|
||||
peer_label: None,
|
||||
};
|
||||
let result = srv.accept_setup(&bad_setup);
|
||||
assert!(matches!(result, Err(MoqError::UnsupportedVersion(_))));
|
||||
let old: [&[u8]; 2] = [b"moq-00", b"moqt-18"];
|
||||
assert!(matches!(srv.select_alpn(old), Err(MoqError::UnsupportedVersion(_))));
|
||||
let ok: [&[u8]; 2] = [b"moqt-18", b"moqt-22"];
|
||||
assert_eq!(srv.select_alpn(ok).unwrap(), Draft::D22);
|
||||
|
||||
// A client receiving PATH from the server.
|
||||
let mut client = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
|
||||
let mut bad = srv.build_setup(false);
|
||||
bad.options.push(KeyValuePair::bytes(opt::PATH, b"/x".to_vec()));
|
||||
assert!(matches!(client.on_peer_setup(&bad, false), Err(MoqError::Wire(WireError::Session { code: SessionError::INVALID_PATH, .. }))));
|
||||
// A server receiving USE_ALIAS in a client's SETUP.
|
||||
let mut c = client.build_setup(false);
|
||||
c.options.push(KeyValuePair::bytes(opt::AUTHORIZATION_TOKEN, Token::UseAlias { alias: 1 }.to_bytes()));
|
||||
assert!(matches!(srv.on_peer_setup(&c, false), Err(MoqError::Wire(WireError::Session { code: SessionError::PROTOCOL_VIOLATION, .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_ids_by_end() {
|
||||
let mut c = MoqSession::with_perspective(SessionRole::Subscriber, Perspective::Client);
|
||||
let mut s = MoqSession::new(SessionRole::Publisher);
|
||||
assert_eq!((c.next_request_id().unwrap(), c.next_request_id().unwrap()), (0, 2));
|
||||
s.accept_peer_request_id(0).unwrap();
|
||||
assert!(s.accept_peer_request_id(0).is_err());
|
||||
assert!(s.accept_peer_request_id(1).is_err());
|
||||
assert_eq!(s.next_request_id().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,75 +1,57 @@
|
|||
//! Wire-level codec primitives for MoQT.
|
||||
//! Wire-level primitives of the MOQT draft this crate implements, over `bytes`
|
||||
//! buffers.
|
||||
//!
|
||||
//! MoQT uses QUIC-style variable-length integers (RFC 9000 §16): the two most
|
||||
//! significant bits of the first byte encode the integer length (1, 2, 4, or
|
||||
//! 8 bytes), the remaining bits hold the value. Strings and byte vectors are
|
||||
//! length-prefixed by varint.
|
||||
//! MOQT's integer is the variable-length integer of
|
||||
//! `draft-ietf-moq-transport-22` §8.1 ([`crate::draft22::varint`]): the number of
|
||||
//! leading 1 bits of the first byte gives the length, 1 to 9 bytes, and every
|
||||
//! 64-bit value is representable. It is not QUIC's (RFC 9000 §16), which drafts
|
||||
//! before -17 used and this module carried before. Byte strings are prefixed by
|
||||
//! their length as such an integer.
|
||||
|
||||
use crate::draft22::varint;
|
||||
use crate::error::{MoqError, MoqResult};
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
|
||||
/// Maximum value representable as a QUIC varint.
|
||||
pub const VARINT_MAX: u64 = (1 << 62) - 1;
|
||||
/// The largest value the integer carries: every `u64`.
|
||||
pub const VARINT_MAX: u64 = varint::VARINT_MAX;
|
||||
|
||||
/// Encode an unsigned integer as a QUIC varint into `dst`.
|
||||
/// Encode an unsigned integer into `dst`, in its shortest form.
|
||||
pub fn encode_varint<B: BufMut>(value: u64, dst: &mut B) -> MoqResult<()> {
|
||||
if value > VARINT_MAX {
|
||||
return Err(MoqError::VarintOverflow);
|
||||
}
|
||||
if value < (1 << 6) {
|
||||
dst.put_u8(value as u8);
|
||||
} else if value < (1 << 14) {
|
||||
dst.put_u16((value as u16) | (0b01 << 14));
|
||||
} else if value < (1 << 30) {
|
||||
dst.put_u32((value as u32) | (0b10 << 30));
|
||||
} else {
|
||||
dst.put_u64(value | (0b11u64 << 62));
|
||||
}
|
||||
let mut tmp = Vec::with_capacity(9);
|
||||
varint::put(&mut tmp, value);
|
||||
dst.put_slice(&tmp);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode a QUIC varint from `src`. Advances the cursor.
|
||||
/// Decode an integer from `src`, advancing the cursor.
|
||||
pub fn decode_varint<B: Buf>(src: &mut B) -> MoqResult<u64> {
|
||||
if !src.has_remaining() {
|
||||
return Err(MoqError::WireDecode("varint: empty buffer".into()));
|
||||
}
|
||||
let first = src.chunk()[0];
|
||||
let len_bits = first >> 6;
|
||||
let len = 1usize << len_bits;
|
||||
let len = varint::len_from_first(src.chunk()[0]);
|
||||
if src.remaining() < len {
|
||||
return Err(MoqError::WireDecode(format!(
|
||||
"varint: need {} bytes, have {}",
|
||||
len,
|
||||
src.remaining()
|
||||
)));
|
||||
return Err(MoqError::WireDecode(format!("varint: need {} bytes, have {}", len, src.remaining())));
|
||||
}
|
||||
let value = match len {
|
||||
1 => (src.get_u8() & 0x3f) as u64,
|
||||
2 => (src.get_u16() & 0x3fff) as u64,
|
||||
4 => (src.get_u32() & 0x3fff_ffff) as u64,
|
||||
8 => src.get_u64() & 0x3fff_ffff_ffff_ffff,
|
||||
_ => unreachable!("len_bits is 2 bits"),
|
||||
};
|
||||
Ok(value)
|
||||
let mut tmp = [0u8; 9];
|
||||
src.copy_to_slice(&mut tmp[..len]);
|
||||
let (v, _) = varint::get(&tmp[..len], "varint")?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Encode a byte slice prefixed by its varint length.
|
||||
/// Encode a byte slice prefixed by its length.
|
||||
pub fn encode_bytes<B: BufMut>(slice: &[u8], dst: &mut B) -> MoqResult<()> {
|
||||
encode_varint(slice.len() as u64, dst)?;
|
||||
dst.put_slice(slice);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode a varint-length-prefixed byte vector.
|
||||
/// Decode a length-prefixed byte vector.
|
||||
pub fn decode_bytes<B: Buf>(src: &mut B) -> MoqResult<Bytes> {
|
||||
let len = decode_varint(src)? as usize;
|
||||
if src.remaining() < len {
|
||||
return Err(MoqError::WireDecode(format!(
|
||||
"bytes: need {} bytes, have {}",
|
||||
len,
|
||||
src.remaining()
|
||||
)));
|
||||
let len = decode_varint(src)?;
|
||||
if (src.remaining() as u64) < len {
|
||||
return Err(MoqError::WireDecode(format!("bytes: need {} bytes, have {}", len, src.remaining())));
|
||||
}
|
||||
let len = len as usize;
|
||||
let mut buf = BytesMut::with_capacity(len);
|
||||
buf.resize(len, 0);
|
||||
src.copy_to_slice(&mut buf);
|
||||
|
|
@ -83,6 +65,21 @@ pub trait WireCodec: Sized {
|
|||
fn decode(src: &mut Bytes) -> MoqResult<Self>;
|
||||
}
|
||||
|
||||
impl WireCodec for crate::draft22::ControlMessage {
|
||||
fn encode(&self, dst: &mut BytesMut) -> MoqResult<()> {
|
||||
dst.put_slice(&crate::draft22::ControlMessage::encode(self)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode(src: &mut Bytes) -> MoqResult<Self> {
|
||||
let mut r = crate::draft22::io::Reader::new(src);
|
||||
let m = crate::draft22::ControlMessage::decode(&mut r)?;
|
||||
let used = r.position();
|
||||
src.advance(used);
|
||||
Ok(m)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -98,16 +95,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn varint_boundaries() {
|
||||
for v in [0u64, 1, 63, 64, 16_383, 16_384, 1_073_741_823, 1_073_741_824, VARINT_MAX] {
|
||||
for v in [0u64, 1, 127, 128, 16_383, 16_384, 1_073_741_823, 1_073_741_824, (1 << 56) - 1, 1 << 56, VARINT_MAX] {
|
||||
roundtrip(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before -17 the integer stopped at 2^62-1 and a larger value was an error.
|
||||
/// In -22 every u64 has a form, so there is no overflow to refuse.
|
||||
#[test]
|
||||
fn varint_overflow() {
|
||||
let mut buf = BytesMut::new();
|
||||
let result = encode_varint(VARINT_MAX + 1, &mut buf);
|
||||
assert!(matches!(result, Err(MoqError::VarintOverflow)));
|
||||
fn every_u64_encodes() {
|
||||
for v in [(1u64 << 62) - 1, 1 << 62, u64::MAX] {
|
||||
roundtrip(v);
|
||||
}
|
||||
let mut b = BytesMut::new();
|
||||
encode_varint(u64::MAX, &mut b).unwrap();
|
||||
assert_eq!(b.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,502 @@
|
|||
//! Generated round trips and hostile input for the `draft-ietf-moq-transport-22`
|
||||
//! codec.
|
||||
//!
|
||||
//! A seeded generator (no randomness from outside the test, so a failure
|
||||
//! reproduces) builds valid control messages of every type, subgroup and fetch
|
||||
//! streams in both Group Orders, datagrams and `WAI_RECEIPT` values. For each,
|
||||
//! encode → decode must give the value back, and encode again the same bytes.
|
||||
//!
|
||||
//! Then the encodings, and every vector of the corpus, are mutated (bits
|
||||
//! flipped, bytes cut, inserted, overwritten, lengths swapped for large ones)
|
||||
//! and fed to every decoder: control messages, whole streams in both orders,
|
||||
//! the incremental decoder in random pieces, datagrams, property lists, tokens,
|
||||
//! the `WAI_RECEIPT` value. Each must return, a value or a refusal; a panic
|
||||
//! fails the test with the input that caused it.
|
||||
|
||||
use joule_moq::carriage::WaiReceiptProperty;
|
||||
use joule_moq::draft22::control::*;
|
||||
use joule_moq::draft22::data::{encode_fetch_stream, encode_subgroup_stream, FetchObject, Limits};
|
||||
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties, SetupOptions};
|
||||
use joule_moq::draft22::params::{ctx, param, ParamContext, Range, RangeFilter};
|
||||
use joule_moq::draft22::{
|
||||
decode_uni_stream, varint, Datagram, DatagramContent, FetchEntry, FetchHeader, GroupOrder, Location, LocationFilter, ObjectDatagram, ObjectStatus,
|
||||
Parameter, Parameters, PublishDoneCode, RangeEnd, RequestErrorCode, StreamHeader, SubgroupHeader, SubgroupId, SubgroupObject, Token, UniEvent,
|
||||
UniStreamDecoder,
|
||||
};
|
||||
use joule_moq::track::{TrackName, TrackNamespace};
|
||||
|
||||
/// xorshift64*: small, seeded, and the same on every host.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
if n == 0 {
|
||||
0
|
||||
} else {
|
||||
self.next() % n
|
||||
}
|
||||
}
|
||||
fn chance(&mut self, num: u64, den: u64) -> bool {
|
||||
self.below(den) < num
|
||||
}
|
||||
/// A value of a random width, so every varint length appears.
|
||||
fn wide(&mut self) -> u64 {
|
||||
let bits = self.below(65) as u32;
|
||||
if bits == 0 {
|
||||
0
|
||||
} else if bits == 64 {
|
||||
self.next()
|
||||
} else {
|
||||
self.next() & ((1u64 << bits) - 1)
|
||||
}
|
||||
}
|
||||
fn small(&mut self) -> u64 {
|
||||
self.below(1000)
|
||||
}
|
||||
fn bytes(&mut self, max: u64) -> Vec<u8> {
|
||||
let n = self.below(max + 1);
|
||||
(0..n).map(|_| self.next() as u8).collect()
|
||||
}
|
||||
fn nonempty(&mut self, max: u64) -> Vec<u8> {
|
||||
let mut b = self.bytes(max.max(1) - 1);
|
||||
b.push(self.next() as u8);
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace(r: &mut Rng) -> TrackNamespace {
|
||||
let n = r.below(5);
|
||||
TrackNamespace((0..n).map(|_| r.nonempty(8)).collect())
|
||||
}
|
||||
|
||||
fn token(r: &mut Rng) -> Token {
|
||||
match r.below(4) {
|
||||
0 => Token::Delete { alias: r.wide() },
|
||||
1 => Token::Register { alias: r.wide(), token_type: r.wide(), value: r.bytes(12) },
|
||||
2 => Token::UseAlias { alias: r.wide() },
|
||||
_ => Token::UseValue { token_type: r.wide(), value: r.bytes(12) },
|
||||
}
|
||||
}
|
||||
|
||||
fn location_filter(r: &mut Rng) -> LocationFilter {
|
||||
let start = Location::new(r.wide() >> 1, r.wide());
|
||||
let delta = r.below(1000);
|
||||
match r.below(6) {
|
||||
0 => LocationFilter::None,
|
||||
1 => LocationFilter::RelativeStart { start_group: r.wide() },
|
||||
2 => LocationFilter::AbsoluteStart { start },
|
||||
3 => LocationFilter::AbsoluteStartGroupEnd { start, end_group_delta: delta },
|
||||
4 => LocationFilter::AbsoluteRange { start, end_group_delta: delta, end_object: r.wide() },
|
||||
_ => LocationFilter::NextObject,
|
||||
}
|
||||
}
|
||||
|
||||
fn ranges(r: &mut Rng, max_value: u64) -> Vec<Range> {
|
||||
let n = r.below(4);
|
||||
let mut out = Vec::new();
|
||||
let mut at = r.below(max_value.min(50) + 1);
|
||||
for i in 0..n {
|
||||
let open = i + 1 == n && r.chance(1, 3);
|
||||
let end = if open { None } else { Some((at + r.below(20)).min(max_value)) };
|
||||
out.push(Range { start: at, end });
|
||||
match end {
|
||||
Some(e) => at = e + r.below(5),
|
||||
None => break,
|
||||
}
|
||||
if at > max_value {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn range_filter(r: &mut Rng, ty: u64, set_id: u8, may_remove: bool) -> Parameter {
|
||||
if may_remove && r.chance(1, 8) {
|
||||
return Parameter::RangeFilter { ty, filter: None };
|
||||
}
|
||||
let property_type = if ty == param::OBJECT_PROPERTY_FILTER || ty == param::TRACK_PROPERTY_FILTER { Some(r.wide() & !1) } else { None };
|
||||
let max = if ty == param::PRIORITY_FILTER { 255 } else { u64::MAX >> 1 };
|
||||
Parameter::RangeFilter { ty, filter: Some(RangeFilter { set_id, property_type, ranges: ranges(r, max) }) }
|
||||
}
|
||||
|
||||
/// A valid parameter block for `c`, in wire order.
|
||||
fn params(r: &mut Rng, c: &ParamContext) -> Parameters {
|
||||
let mut out = Vec::new();
|
||||
for &ty in c.allowed {
|
||||
if !r.chance(1, 3) {
|
||||
continue;
|
||||
}
|
||||
let p = match ty {
|
||||
param::OBJECT_DELIVERY_TIMEOUT => Parameter::ObjectDeliveryTimeout(r.wide()),
|
||||
param::AUTHORIZATION_TOKEN => {
|
||||
for _ in 0..r.below(2) {
|
||||
out.push(Parameter::AuthorizationToken(token(r)));
|
||||
}
|
||||
Parameter::AuthorizationToken(token(r))
|
||||
}
|
||||
param::RENDEZVOUS_TIMEOUT => Parameter::RendezvousTimeout(r.wide()),
|
||||
param::SUBGROUP_DELIVERY_TIMEOUT => Parameter::SubgroupDeliveryTimeout(r.wide()),
|
||||
param::EXPIRES => Parameter::Expires(r.wide()),
|
||||
param::LARGEST_OBJECT => Parameter::LargestObject(Location::new(r.wide(), r.wide())),
|
||||
param::FILL_TIMEOUT => Parameter::FillTimeout(r.wide()),
|
||||
param::FORWARD => Parameter::Forward(r.chance(1, 2)),
|
||||
param::SUBSCRIBER_PRIORITY => Parameter::SubscriberPriority(r.next() as u8),
|
||||
param::LOCATION_FILTER => Parameter::LocationFilter(location_filter(r)),
|
||||
param::GROUP_ORDER => Parameter::GroupOrder(if r.chance(1, 2) { GroupOrder::Ascending } else { GroupOrder::Descending }),
|
||||
param::FILL_PARAMETERS => Parameter::FillParameters(params(r, &ctx::FILL_PARAMETERS_INNER)),
|
||||
t if param::is_range_filter(t) => {
|
||||
// Up to three, each with a SetID of its own.
|
||||
let n = 1 + r.below(3);
|
||||
for s in 1..n {
|
||||
out.push(range_filter(r, t, s as u8, false));
|
||||
}
|
||||
// A removal (Length 0) has no SetID, so only one may be in a block.
|
||||
range_filter(r, t, 0, true)
|
||||
}
|
||||
param::NEW_GROUP_REQUEST => Parameter::NewGroupRequest(r.wide()),
|
||||
param::TRACK_NAMESPACE_PREFIX => Parameter::TrackNamespacePrefix(namespace(r)),
|
||||
param::INCLUDE_PROPERTIES => Parameter::IncludeProperties(r.chance(1, 2)),
|
||||
other => panic!("no generator for {other:#x}"),
|
||||
};
|
||||
out.push(p);
|
||||
}
|
||||
out.sort_by_key(|p| p.type_code());
|
||||
Parameters(out)
|
||||
}
|
||||
|
||||
/// Pairs that pass the checks of an object's or a track's properties: types
|
||||
/// from the application ranges and the timeouts, an Immutable Properties pair
|
||||
/// holding more of the same.
|
||||
fn safe_pairs(r: &mut Rng, nest: bool) -> Vec<KeyValuePair> {
|
||||
let pool = [0x02u64, 0x06, 0x78, 0x79, 0x7e, 0x7f, 0x3800, 0x3a40, 0x3a41, 0x3a45, 0x3fff];
|
||||
let mut out: Vec<KeyValuePair> = (0..r.below(4))
|
||||
.map(|_| {
|
||||
let ty = pool[r.below(pool.len() as u64) as usize];
|
||||
if ty.is_multiple_of(2) {
|
||||
KeyValuePair::varint(ty, r.wide())
|
||||
} else {
|
||||
KeyValuePair::bytes(ty, r.bytes(16))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if nest && r.chance(1, 3) {
|
||||
let inner = safe_pairs(r, false);
|
||||
let mut b = Vec::new();
|
||||
joule_moq::draft22::kvp::encode_pairs(&inner, &mut b).unwrap();
|
||||
out.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
|
||||
}
|
||||
out.sort_by_key(|k| k.ty);
|
||||
out
|
||||
}
|
||||
|
||||
fn properties(r: &mut Rng) -> Properties {
|
||||
Properties(safe_pairs(r, true))
|
||||
}
|
||||
|
||||
fn setup(r: &mut Rng) -> Setup {
|
||||
let mut o = SetupOptions::new();
|
||||
for &(ty, bytes) in &[(0x01u64, true), (0x04, false), (0x05, true), (0x06, false), (0x07, true), (0x08, false), (0x9d, true), (0x3a46, false)] {
|
||||
if r.chance(1, 2) {
|
||||
o.push(if bytes { KeyValuePair::bytes(ty, r.bytes(20)) } else { KeyValuePair::varint(ty, r.wide()) });
|
||||
}
|
||||
}
|
||||
for _ in 0..r.below(3) {
|
||||
o.push(KeyValuePair::bytes(0x03, token(r).to_bytes()));
|
||||
}
|
||||
o.0.sort_by_key(|p| p.ty);
|
||||
Setup { options: o }
|
||||
}
|
||||
|
||||
fn track_request(r: &mut Rng, c: &ParamContext) -> TrackRequest {
|
||||
TrackRequest { request_id: r.wide(), namespace: namespace(r), name: TrackName(r.bytes(8)), params: params(r, c) }
|
||||
}
|
||||
|
||||
fn ns_request(r: &mut Rng, c: &ParamContext) -> NamespaceRequest {
|
||||
NamespaceRequest { request_id: r.wide(), prefix: namespace(r), params: params(r, c) }
|
||||
}
|
||||
|
||||
fn control(r: &mut Rng, which: u64) -> ControlMessage {
|
||||
match which % 19 {
|
||||
0 => ControlMessage::Setup(setup(r)),
|
||||
1 => ControlMessage::Goaway(Goaway { new_session_uri: r.bytes(40), timeout_ms: r.wide() }),
|
||||
2 => ControlMessage::Subscribe(track_request(r, &ctx::SUBSCRIBE)),
|
||||
3 => ControlMessage::SubscribeOk(SubscribeOk { track_alias: r.wide(), params: params(r, &ctx::SUBSCRIBE_OK), track_properties: properties(r) }),
|
||||
4 => ControlMessage::PublishStateNotify(params(r, &ctx::PUBLISH_STATE_NOTIFY)),
|
||||
5 => ControlMessage::Publish(Publish {
|
||||
request_id: r.wide(),
|
||||
namespace: namespace(r),
|
||||
name: TrackName(r.bytes(8)),
|
||||
track_alias: r.wide(),
|
||||
params: params(r, &ctx::PUBLISH),
|
||||
track_properties: properties(r),
|
||||
}),
|
||||
6 => ControlMessage::PublishDone(PublishDone { status: PublishDoneCode(r.wide()), stream_count: r.wide(), reason: r.bytes(30) }),
|
||||
7 => ControlMessage::Fetch(track_request(r, &ctx::FETCH)),
|
||||
8 => ControlMessage::FetchOk(FetchOk { end_of_track: r.chance(1, 2), end_location: Location::new(r.wide(), r.wide()), params: Parameters::new(), track_properties: properties(r) }),
|
||||
9 => ControlMessage::TrackStatus(track_request(r, &ctx::TRACK_STATUS)),
|
||||
10 => ControlMessage::PublishNamespace(ns_request(r, &ctx::PUBLISH_NAMESPACE)),
|
||||
11 => ControlMessage::SubscribeNamespace(ns_request(r, &ctx::SUBSCRIBE_NAMESPACE)),
|
||||
12 => ControlMessage::SubscribeTracks(ns_request(r, &ctx::SUBSCRIBE_TRACKS)),
|
||||
13 => ControlMessage::Namespace(namespace(r)),
|
||||
14 => ControlMessage::NamespaceDone(namespace(r)),
|
||||
15 => ControlMessage::PublishSkipped(PublishSkipped { suffix: namespace(r), name: TrackName(r.bytes(8)) }),
|
||||
16 => ControlMessage::RequestUpdate(RequestUpdate { request_id: r.wide(), params: params(r, &ctx::REQUEST_UPDATE) }),
|
||||
17 => ControlMessage::RequestOk(RequestOk { params: params(r, &ctx::REQUEST_OK), track_properties: properties(r) }),
|
||||
_ => {
|
||||
let redirect = r.chance(1, 3);
|
||||
ControlMessage::RequestError(RequestError {
|
||||
code: if redirect { RequestErrorCode::REDIRECT } else { RequestErrorCode(r.wide() & !0x34 | (r.below(2) * 0x100)) },
|
||||
retry_interval: r.wide(),
|
||||
reason: r.bytes(30),
|
||||
redirect: redirect.then(|| {
|
||||
let ns = namespace(r);
|
||||
Redirect { connect_uri: r.bytes(30), namespace: ns, name: TrackName(r.bytes(8)) }
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subgroup(r: &mut Rng) -> (SubgroupHeader, Vec<SubgroupObject>) {
|
||||
let h = SubgroupHeader {
|
||||
track_alias: r.wide(),
|
||||
group_id: r.wide(),
|
||||
subgroup_id: match r.below(3) {
|
||||
0 => SubgroupId::Zero,
|
||||
1 => SubgroupId::FirstObjectId,
|
||||
_ => SubgroupId::Explicit(r.wide()),
|
||||
},
|
||||
publisher_priority: r.chance(1, 2).then(|| r.next() as u8),
|
||||
properties: r.chance(1, 2),
|
||||
end_of_group: r.chance(1, 2),
|
||||
first_object: r.chance(1, 2),
|
||||
};
|
||||
let mut id = r.small();
|
||||
let objs = (0..r.below(6))
|
||||
.map(|i| {
|
||||
if i > 0 {
|
||||
id += 1 + r.below(4);
|
||||
}
|
||||
let status = match r.below(5) {
|
||||
0 => ObjectStatus::EndOfGroup,
|
||||
1 => ObjectStatus::EndOfTrack,
|
||||
_ => ObjectStatus::Normal,
|
||||
};
|
||||
let normal = status == ObjectStatus::Normal;
|
||||
// Object properties: no Prior Group or Object ID Gap, so no gap can pass its ID.
|
||||
let properties = if h.properties && normal { properties(r) } else { Properties::new() };
|
||||
let payload = if normal { r.bytes(24) } else { vec![] };
|
||||
SubgroupObject { object_id: id, properties, status, payload }
|
||||
})
|
||||
.collect();
|
||||
(h, objs)
|
||||
}
|
||||
|
||||
fn fetch(r: &mut Rng, order: GroupOrder) -> Vec<FetchEntry> {
|
||||
let mut g: u64 = match order {
|
||||
GroupOrder::Ascending => r.small(),
|
||||
GroupOrder::Descending => 10_000 + r.small(),
|
||||
};
|
||||
let mut o: u64 = r.small();
|
||||
let mut first = true;
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..r.below(8) {
|
||||
let move_group = first || r.chance(1, 3);
|
||||
if move_group && !first {
|
||||
let step = 1 + r.below(5);
|
||||
g = match order {
|
||||
GroupOrder::Ascending => g + step,
|
||||
GroupOrder::Descending => g - step,
|
||||
};
|
||||
o = r.small();
|
||||
} else if !first {
|
||||
o += 1 + r.below(3);
|
||||
}
|
||||
if move_group && r.chance(1, 4) {
|
||||
out.push(FetchEntry::EndOfRange { kind: [RangeEnd::NonExistent, RangeEnd::Unknown, RangeEnd::TimedOut][r.below(3) as usize], location: Location::new(g, o) });
|
||||
} else {
|
||||
out.push(FetchEntry::Object(FetchObject {
|
||||
group_id: g,
|
||||
object_id: o,
|
||||
subgroup_id: r.chance(4, 5).then(|| r.below(4)),
|
||||
publisher_priority: r.below(3) as u8,
|
||||
properties: r.chance(1, 3).then(|| properties(r)),
|
||||
payload: r.bytes(16),
|
||||
}));
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn datagram(r: &mut Rng) -> ObjectDatagram {
|
||||
let status = r.chance(1, 3);
|
||||
let st = [ObjectStatus::Normal, ObjectStatus::EndOfGroup, ObjectStatus::EndOfTrack][r.below(3) as usize];
|
||||
let with_props = r.chance(1, 2) && (!status || st == ObjectStatus::Normal);
|
||||
let mut props = properties(r);
|
||||
if props.is_empty() {
|
||||
props.push(KeyValuePair::varint(0x3a40, r.wide()));
|
||||
}
|
||||
ObjectDatagram {
|
||||
track_alias: r.wide(),
|
||||
group_id: r.wide(),
|
||||
object_id: if r.chance(1, 3) { 0 } else { r.wide() },
|
||||
publisher_priority: r.chance(1, 2).then(|| r.next() as u8),
|
||||
properties: with_props.then_some(props),
|
||||
end_of_group: !status && r.chance(1, 2),
|
||||
content: if status { DatagramContent::Status(st) } else { DatagramContent::Payload(r.bytes(24)) },
|
||||
}
|
||||
}
|
||||
|
||||
/// Every decoder, on bytes that may be anything: each must return.
|
||||
fn feed_everything(b: &[u8], r: &mut Rng) {
|
||||
let _ = varint::get(b, "x");
|
||||
let _ = ControlMessage::decode_exact(b);
|
||||
let _ = decode_uni_stream(b, GroupOrder::Ascending);
|
||||
let _ = decode_uni_stream(b, GroupOrder::Descending);
|
||||
let _ = Datagram::decode(b);
|
||||
let _ = Properties::decode(b, "x").map(|p| (p.check_object(0, 0), p.check_track(), p.immutable()));
|
||||
let _ = Token::decode(b);
|
||||
let _ = WaiReceiptProperty::from_bytes(b).map(|p| p.check());
|
||||
let mut d = UniStreamDecoder::new(GroupOrder::Ascending, Limits::default());
|
||||
let mut at = 0;
|
||||
'feed: while at < b.len() {
|
||||
let n = (1 + r.below(9) as usize).min(b.len() - at);
|
||||
d.push(&b[at..at + n]);
|
||||
at += n;
|
||||
loop {
|
||||
match d.next() {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(_) => break 'feed,
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = d.finish();
|
||||
}
|
||||
|
||||
fn mutate(b: &[u8], r: &mut Rng) -> Vec<u8> {
|
||||
let mut m = b.to_vec();
|
||||
for _ in 0..1 + r.below(3) {
|
||||
if m.is_empty() {
|
||||
m.push(r.next() as u8);
|
||||
continue;
|
||||
}
|
||||
let i = r.below(m.len() as u64) as usize;
|
||||
match r.below(6) {
|
||||
0 => m[i] ^= 1 << r.below(8),
|
||||
1 => m.truncate(i),
|
||||
2 => m.insert(i, r.next() as u8),
|
||||
3 => m[i] = r.next() as u8,
|
||||
4 => {
|
||||
// A length or a count swapped for a huge one.
|
||||
let big = varint::to_vec(u64::MAX - r.below(3));
|
||||
m.splice(i..i + 1, big);
|
||||
}
|
||||
_ => m[i] = [0x00, 0x7f, 0x80, 0xbf, 0xc0, 0xfe, 0xff][r.below(7) as usize],
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_decode_encode_is_the_identity() {
|
||||
let mut r = Rng(0x6d6f_7174_2d32_3201);
|
||||
for i in 0..4000u64 {
|
||||
// Control messages, each type in turn.
|
||||
let m = control(&mut r, i);
|
||||
let b = m.encode().unwrap_or_else(|e| panic!("{} did not encode: {e}\n{m:?}", m.name()));
|
||||
let back = ControlMessage::decode_exact(&b).unwrap_or_else(|e| panic!("{} did not decode: {e}\n{}", m.name(), json_hex(&b)));
|
||||
assert_eq!(back, m, "{}", json_hex(&b));
|
||||
assert_eq!(back.encode().unwrap(), b);
|
||||
|
||||
// A subgroup stream.
|
||||
let (h, objs) = subgroup(&mut r);
|
||||
let b = encode_subgroup_stream(&h, &objs).unwrap();
|
||||
let ev = decode_uni_stream(&b, GroupOrder::Ascending).unwrap_or_else(|e| panic!("subgroup: {e}\n{}", json_hex(&b)));
|
||||
let mut want = vec![UniEvent::Header(StreamHeader::Subgroup(h))];
|
||||
want.extend(objs.iter().cloned().map(UniEvent::SubgroupObject));
|
||||
assert_eq!(ev, want);
|
||||
assert_eq!(encode_subgroup_stream(&h, &objs).unwrap(), b);
|
||||
|
||||
// A fetch stream, in each order.
|
||||
for order in [GroupOrder::Ascending, GroupOrder::Descending] {
|
||||
let entries = fetch(&mut r, order);
|
||||
let fh = FetchHeader { request_id: r.wide() };
|
||||
let b = encode_fetch_stream(&fh, order, &entries).unwrap_or_else(|e| panic!("fetch did not encode: {e}\n{entries:?}"));
|
||||
let ev = decode_uni_stream(&b, order).unwrap_or_else(|e| panic!("fetch: {e}\n{}", json_hex(&b)));
|
||||
let mut want = vec![UniEvent::Header(StreamHeader::Fetch(fh))];
|
||||
want.extend(entries.iter().cloned().map(UniEvent::FetchEntry));
|
||||
assert_eq!(ev, want);
|
||||
}
|
||||
|
||||
// A datagram.
|
||||
let d = datagram(&mut r);
|
||||
let b = d.encode().unwrap_or_else(|e| panic!("datagram did not encode: {e}\n{d:?}"));
|
||||
assert_eq!(Datagram::decode(&b).unwrap(), Datagram::Object(d.clone()));
|
||||
|
||||
// A WAI_RECEIPT value.
|
||||
let mut ch = [0u8; 32];
|
||||
ch.iter_mut().for_each(|x| *x = r.next() as u8);
|
||||
let p = WaiReceiptProperty { content_hash: ch, joules_micro: r.wide(), acquisition: String::from_utf8_lossy(&r.bytes(20)).into_owned(), acquisition_evidence: r.bytes(20), group_receipt: ch };
|
||||
assert_eq!(WaiReceiptProperty::from_bytes(&p.to_bytes()).unwrap(), p);
|
||||
|
||||
// Every varint length.
|
||||
let v = r.wide();
|
||||
let len = varint::encoded_len(v) + r.below((10 - varint::encoded_len(v)) as u64) as usize;
|
||||
let mut vb = Vec::new();
|
||||
varint::put_with_len(&mut vb, v, len.min(9)).unwrap();
|
||||
assert_eq!(varint::get(&vb, "v").unwrap(), (v, vb.len()));
|
||||
}
|
||||
}
|
||||
|
||||
fn json_hex(b: &[u8]) -> String {
|
||||
joule_moq::draft22::json::hex(b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_input_panics_any_decoder() {
|
||||
let mut r = Rng(0x686f_7374_696c_6501);
|
||||
let corpus: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/draft-22/vectors.json")).unwrap()).unwrap();
|
||||
let mut seeds: Vec<Vec<u8>> = corpus["vectors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let h = v["hex"].as_str().unwrap();
|
||||
(0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect()
|
||||
})
|
||||
.collect();
|
||||
for i in 0..300u64 {
|
||||
seeds.push(control(&mut r, i).encode().unwrap());
|
||||
let (h, objs) = subgroup(&mut r);
|
||||
seeds.push(encode_subgroup_stream(&h, &objs).unwrap());
|
||||
seeds.push(datagram(&mut r).encode().unwrap());
|
||||
}
|
||||
assert!(!seeds.is_empty());
|
||||
let mut fed = 0u64;
|
||||
for round in 0..25 {
|
||||
for s in &seeds {
|
||||
let m = if round == 0 { s.clone() } else { mutate(s, &mut r) };
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut r2 = Rng(m.len() as u64 + 1);
|
||||
feed_everything(&m, &mut r2);
|
||||
}));
|
||||
assert!(caught.is_ok(), "a decoder panicked on {}", json_hex(&m));
|
||||
fed += 1;
|
||||
}
|
||||
// Bytes with no structure at all.
|
||||
let noise = r.bytes(64);
|
||||
feed_everything(&noise, &mut r);
|
||||
}
|
||||
assert!(fed > 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,768 @@
|
|||
//! The `draft-ietf-moq-transport-22` vector corpus (`tests/vectors/draft-22/`).
|
||||
//!
|
||||
//! Every vector is built here from typed values, deterministically: the bytes
|
||||
//! are what this crate's encoder makes of the value (or, for a negative vector
|
||||
//! and the few forms the encoder never makes, bytes written out by hand), and
|
||||
//! the expected reading is the value itself in the JSON view of
|
||||
//! `joule_moq::draft22::json`, or, for a negative vector, the refusal it must
|
||||
//! meet and the draft's consequence. Expectations are stated here, never read
|
||||
//! back from the decoder.
|
||||
//!
|
||||
//! This test then holds the crate to the corpus:
|
||||
//!
|
||||
//! - the committed `vectors.json` is exactly what this file generates (run with
|
||||
//! `JOULE_MOQ_WRITE_VECTORS=1` to rewrite it);
|
||||
//! - each good vector decodes to its stated reading, and, where it is the form
|
||||
//! the encoder makes, re-encodes to its bytes;
|
||||
//! - each negative vector is refused with its stated kind and consequence, by
|
||||
//! the whole-stream decoder and, for streams, by the incremental one too.
|
||||
//!
|
||||
//! `vectors/draft-22/moqt22_check.py` decodes the same corpus independently.
|
||||
|
||||
use joule_moq::carriage::WaiReceiptProperty;
|
||||
use joule_moq::draft22::control::*;
|
||||
use joule_moq::draft22::data::{
|
||||
encode_control_stream, encode_fetch_stream, encode_padding_stream, encode_subgroup_stream, FetchCodec, FetchObject, Limits, SubgroupCodec,
|
||||
};
|
||||
use joule_moq::draft22::kvp::{opt, prop, KeyValuePair, Properties, SetupOptions};
|
||||
use joule_moq::draft22::params::{param, Range, RangeFilter};
|
||||
use joule_moq::draft22::{
|
||||
decode_uni_stream, json, varint, Datagram, DatagramContent, FetchEntry, FetchHeader, GroupOrder, Location, LocationFilter, ObjectDatagram,
|
||||
ObjectStatus, Parameter, Parameters, PublishDoneCode, RangeEnd, RequestErrorCode, StreamHeader, SubgroupHeader, SubgroupId, SubgroupObject,
|
||||
Token, UniEvent, UniStreamDecoder, WireError,
|
||||
};
|
||||
use joule_moq::track::{TrackName, TrackNamespace};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/draft-22/vectors.json");
|
||||
|
||||
fn hx(b: &[u8]) -> String {
|
||||
json::hex(b)
|
||||
}
|
||||
|
||||
fn ns(fields: &[&str]) -> TrackNamespace {
|
||||
TrackNamespace::from_strs(fields)
|
||||
}
|
||||
|
||||
fn name(s: &str) -> TrackName {
|
||||
TrackName::from_str(s)
|
||||
}
|
||||
|
||||
fn kv(ty: u64, v: u64) -> KeyValuePair {
|
||||
KeyValuePair::varint(ty, v)
|
||||
}
|
||||
|
||||
fn kb(ty: u64, b: &[u8]) -> KeyValuePair {
|
||||
KeyValuePair::bytes(ty, b.to_vec())
|
||||
}
|
||||
|
||||
/// A property list in wire order (by type, stably), as a decoder reads it.
|
||||
fn props(mut ps: Vec<KeyValuePair>) -> Properties {
|
||||
ps.sort_by_key(|k| k.ty);
|
||||
Properties(ps)
|
||||
}
|
||||
|
||||
/// Properties with an Immutable Properties pair holding `inner`.
|
||||
fn with_immutable(mut outer: Vec<KeyValuePair>, inner: Vec<KeyValuePair>) -> Properties {
|
||||
let mut b = Vec::new();
|
||||
joule_moq::draft22::kvp::encode_pairs(&inner, &mut b).unwrap();
|
||||
outer.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, b));
|
||||
let mut p = Properties(outer);
|
||||
p.0.sort_by_key(|k| k.ty);
|
||||
p
|
||||
}
|
||||
|
||||
fn rf(ty: u64, set_id: u8, property_type: Option<u64>, ranges: &[(u64, Option<u64>)]) -> Parameter {
|
||||
Parameter::RangeFilter {
|
||||
ty,
|
||||
filter: Some(RangeFilter { set_id, property_type, ranges: ranges.iter().map(|&(start, end)| Range { start, end }).collect() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a parameter list as the wire orders it (by type, stably), so the
|
||||
/// stated reading is the one a decoder gives.
|
||||
fn sorted(ps: Vec<Parameter>) -> Parameters {
|
||||
let mut v = ps;
|
||||
v.sort_by_key(|p| p.type_code());
|
||||
Parameters(v)
|
||||
}
|
||||
|
||||
struct Corpus {
|
||||
vectors: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Corpus {
|
||||
fn push(&mut self, name: &str, kind: &str, bytes: &[u8], canonical: bool, context: Option<Value>, expect: Value) {
|
||||
let mut v = json!({"name": name, "kind": kind, "hex": hx(bytes), "canonical": canonical, "expect": expect});
|
||||
if let Some(c) = context {
|
||||
v["context"] = c;
|
||||
}
|
||||
self.vectors.push(v);
|
||||
}
|
||||
|
||||
fn control(&mut self, name: &str, m: ControlMessage) {
|
||||
let b = m.encode().expect(name);
|
||||
self.push(name, "control", &b, true, None, json::control(&m));
|
||||
}
|
||||
|
||||
fn stream(&mut self, name: &str, bytes: Vec<u8>, order: GroupOrder, events: Vec<UniEvent>) {
|
||||
let expect = Value::Array(events.iter().map(json::uni_event).collect());
|
||||
self.push(name, "uni-stream", &bytes, true, Some(json!({"group_order": order as u8})), expect);
|
||||
}
|
||||
|
||||
fn datagram(&mut self, name: &str, d: Datagram, bytes: Vec<u8>, canonical: bool) {
|
||||
self.push(name, "datagram", &bytes, canonical, None, json::datagram(&d));
|
||||
}
|
||||
|
||||
fn bad(&mut self, name: &str, kind: &str, bytes: &[u8], context: Option<Value>, error: &str, consequence: &str) {
|
||||
self.push(name, kind, bytes, false, context, json!({"error": error, "consequence": consequence}));
|
||||
}
|
||||
}
|
||||
|
||||
const PV: &str = "session:PROTOCOL_VIOLATION";
|
||||
|
||||
/// Frame a control message body by hand: type, a 16-bit length, the body.
|
||||
fn frame(ty: u64, body: &[u8]) -> Vec<u8> {
|
||||
let mut out = varint::to_vec(ty);
|
||||
out.extend_from_slice(&(body.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
fn v(x: u64) -> Vec<u8> {
|
||||
varint::to_vec(x)
|
||||
}
|
||||
|
||||
fn cat(parts: &[&[u8]]) -> Vec<u8> {
|
||||
parts.concat()
|
||||
}
|
||||
|
||||
fn wai_receipt(content: u8, joules: u64, class: &str, evidence: Vec<u8>, group: u8) -> WaiReceiptProperty {
|
||||
WaiReceiptProperty { content_hash: [content; 32], joules_micro: joules, acquisition: class.into(), acquisition_evidence: evidence, group_receipt: [group; 32] }
|
||||
}
|
||||
|
||||
fn wai_json(p: &WaiReceiptProperty) -> Value {
|
||||
json!({
|
||||
"content_hash": hx(&p.content_hash),
|
||||
"joules_micro": p.joules_micro,
|
||||
"acquisition": p.acquisition,
|
||||
"acquisition_evidence": hx(&p.acquisition_evidence),
|
||||
"group_receipt": hx(&p.group_receipt),
|
||||
})
|
||||
}
|
||||
|
||||
fn build() -> Vec<Value> {
|
||||
let mut c = Corpus { vectors: Vec::new() };
|
||||
|
||||
// ── §8.1 the variable-length integer: Table 4 of the draft ──
|
||||
for (h, val, shortest) in [
|
||||
("25", 37u64, true),
|
||||
("8025", 37, false),
|
||||
("bbbd", 15_293, true),
|
||||
("ed7f3e7d", 226_442_877, true),
|
||||
("faa1a0e403d8", 2_893_212_287_960, true),
|
||||
("fc8998abc66bc0", 151_288_809_941_952, true),
|
||||
("fefa318fa8e3ca11", 70_423_237_261_249_041, true),
|
||||
("ffffffffffffffffff", u64::MAX, true),
|
||||
] {
|
||||
let b: Vec<u8> = (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect();
|
||||
c.push(&format!("varint {h}"), "varint", &b, shortest, None, json!({"value": val, "length": b.len()}));
|
||||
}
|
||||
c.push("varint 0 in three bytes", "varint", &[0xc0, 0x00, 0x00], false, None, json!({"value": 0, "length": 3}));
|
||||
c.bad("varint cut short", "varint", &[0xe0, 0x01], None, "truncated", PV);
|
||||
|
||||
// ── §9.1 SETUP ──
|
||||
let mut client = SetupOptions::new();
|
||||
client
|
||||
.push(kb(opt::PATH, b"/live?room=1"))
|
||||
.push(kb(opt::AUTHORIZATION_TOKEN, &Token::Register { alias: 1, token_type: 0, value: b"tok".to_vec() }.to_bytes()))
|
||||
.push(kv(opt::MAX_AUTH_TOKEN_CACHE_SIZE, 1024))
|
||||
.push(kb(opt::AUTHORITY, b"relay.example:443"))
|
||||
.push(kv(opt::MAX_FILTER_RANGES, 4))
|
||||
.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0"))
|
||||
.push(kv(opt::MAX_REQUEST_UPDATES, 2))
|
||||
.push(kb(0x9d, b"grease"))
|
||||
.push(kv(0x3a46, 2));
|
||||
let mut client_sorted = client.clone();
|
||||
client_sorted.0.sort_by_key(|p| p.ty);
|
||||
c.push("SETUP from a client", "control", &ControlMessage::Setup(Setup { options: client }).encode().unwrap(), true, None,
|
||||
json::control(&ControlMessage::Setup(Setup { options: client_sorted })));
|
||||
let mut server = SetupOptions::new();
|
||||
server.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0"));
|
||||
c.control("SETUP from a server", ControlMessage::Setup(Setup { options: server }));
|
||||
c.control("SETUP with no options", ControlMessage::Setup(Setup::default()));
|
||||
|
||||
// ── §9.2 GOAWAY ──
|
||||
c.control("GOAWAY with a new session URI", ControlMessage::Goaway(Goaway { new_session_uri: b"moqt://relay.example/next".to_vec(), timeout_ms: 5000 }));
|
||||
c.control("GOAWAY with no URI and no timeout", ControlMessage::Goaway(Goaway::default()));
|
||||
|
||||
// ── §9.6 SUBSCRIBE, with every parameter it may carry ──
|
||||
let every = sorted(vec![
|
||||
Parameter::ObjectDeliveryTimeout(250),
|
||||
Parameter::AuthorizationToken(Token::UseValue { token_type: 0, value: b"jwt".to_vec() }),
|
||||
Parameter::RendezvousTimeout(1000),
|
||||
Parameter::SubgroupDeliveryTimeout(500),
|
||||
Parameter::Forward(true),
|
||||
Parameter::SubscriberPriority(10),
|
||||
Parameter::LocationFilter(LocationFilter::NextObject),
|
||||
Parameter::GroupOrder(GroupOrder::Descending),
|
||||
Parameter::FillParameters(sorted(vec![Parameter::LocationFilter(LocationFilter::RelativeStart { start_group: 1 }), Parameter::FillTimeout(100)])),
|
||||
rf(param::SUBGROUP_FILTER, 0, None, &[(0, Some(2))]),
|
||||
rf(param::OBJECTID_FILTER, 0, None, &[(3, Some(5)), (10, None)]),
|
||||
rf(param::PRIORITY_FILTER, 1, None, &[(0, Some(127))]),
|
||||
rf(param::OBJECT_PROPERTY_FILTER, 0, Some(0x3a40), &[(0, Some(2))]),
|
||||
Parameter::NewGroupRequest(0),
|
||||
Parameter::IncludeProperties(true),
|
||||
]);
|
||||
c.control("SUBSCRIBE with every parameter", ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["example", "cam1"]), name: name("video"), params: every }));
|
||||
c.control("SUBSCRIBE with no parameters, an empty namespace", ControlMessage::Subscribe(TrackRequest { request_id: 2, namespace: TrackNamespace(vec![]), name: name("t"), params: Parameters::new() }));
|
||||
// A Request ID in a longer form than it needs: the reading is the same.
|
||||
let sub = ControlMessage::Subscribe(TrackRequest { request_id: 5, namespace: ns(&["a"]), name: name("b"), params: Parameters::new() });
|
||||
let mut body = Vec::new();
|
||||
varint::put_with_len(&mut body, 5, 3).unwrap();
|
||||
body.extend_from_slice(&cat(&[&v(1), &v(1), b"a", &v(1), b"b", &v(0)]));
|
||||
c.push("SUBSCRIBE whose Request ID takes three bytes", "control", &frame(msg::SUBSCRIBE, &body), false, None, json::control(&sub));
|
||||
|
||||
// ── §9.7 SUBSCRIBE_OK ──
|
||||
c.control(
|
||||
"SUBSCRIBE_OK with track properties",
|
||||
ControlMessage::SubscribeOk(SubscribeOk {
|
||||
track_alias: 7,
|
||||
params: sorted(vec![Parameter::Expires(30_000), Parameter::LargestObject(Location::new(5, 2))]),
|
||||
track_properties: with_immutable(
|
||||
vec![
|
||||
kv(prop::OBJECT_DELIVERY_TIMEOUT, 200),
|
||||
kv(prop::MAX_CACHE_DURATION, 10_000),
|
||||
kv(prop::DEFAULT_PUBLISHER_PRIORITY, 64),
|
||||
kv(prop::DEFAULT_PUBLISHER_GROUP_ORDER, 1),
|
||||
kv(prop::DYNAMIC_GROUPS, 1),
|
||||
kb(0x3a45, b"x"),
|
||||
],
|
||||
vec![kv(0x78, 5)],
|
||||
),
|
||||
}),
|
||||
);
|
||||
// ── §9.10 PUBLISH_STATE_NOTIFY ──
|
||||
c.control(
|
||||
"PUBLISH_STATE_NOTIFY",
|
||||
ControlMessage::PublishStateNotify(sorted(vec![
|
||||
Parameter::LargestObject(Location::new(9, 0)),
|
||||
Parameter::Forward(false),
|
||||
Parameter::LocationFilter(LocationFilter::AbsoluteStart { start: Location::new(9, 0) }),
|
||||
])),
|
||||
);
|
||||
// ── §9.8 PUBLISH ──
|
||||
c.control(
|
||||
"PUBLISH",
|
||||
ControlMessage::Publish(Publish {
|
||||
request_id: 1,
|
||||
namespace: ns(&["example", "cam1"]),
|
||||
name: name("audio"),
|
||||
track_alias: 3,
|
||||
params: sorted(vec![
|
||||
Parameter::Expires(60_000),
|
||||
Parameter::LargestObject(Location::new(4, 9)),
|
||||
Parameter::Forward(true),
|
||||
Parameter::GroupOrder(GroupOrder::Ascending),
|
||||
Parameter::LocationFilter(LocationFilter::AbsoluteStartGroupEnd { start: Location::new(4, 0), end_group_delta: 2 }),
|
||||
]),
|
||||
track_properties: props(vec![kv(prop::DYNAMIC_GROUPS, 0)]),
|
||||
}),
|
||||
);
|
||||
// ── §9.9 PUBLISH_DONE ──
|
||||
c.control("PUBLISH_DONE", ControlMessage::PublishDone(PublishDone { status: PublishDoneCode::TRACK_ENDED, stream_count: 3, reason: b"done".to_vec() }));
|
||||
c.control("PUBLISH_DONE with a stream count of 2^64-1", ControlMessage::PublishDone(PublishDone { status: PublishDoneCode::TOO_FAR_BEHIND, stream_count: u64::MAX, reason: vec![] }));
|
||||
// ── §9.11 FETCH, §9.12 FETCH_OK ──
|
||||
c.control(
|
||||
"FETCH",
|
||||
ControlMessage::Fetch(TrackRequest {
|
||||
request_id: 4,
|
||||
namespace: ns(&["example", "cam1"]),
|
||||
name: name("video"),
|
||||
params: sorted(vec![
|
||||
Parameter::AuthorizationToken(Token::UseAlias { alias: 1 }),
|
||||
Parameter::FillTimeout(0),
|
||||
Parameter::SubscriberPriority(200),
|
||||
Parameter::LocationFilter(LocationFilter::AbsoluteRange { start: Location::new(1, 0), end_group_delta: 3, end_object: 7 }),
|
||||
Parameter::GroupOrder(GroupOrder::Descending),
|
||||
Parameter::IncludeProperties(false),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
c.control(
|
||||
"FETCH_OK at the end of the track",
|
||||
ControlMessage::FetchOk(FetchOk { end_of_track: true, end_location: Location::new(4, 7), params: Parameters::new(), track_properties: props(vec![kv(prop::DEFAULT_PUBLISHER_PRIORITY, 128)]) }),
|
||||
);
|
||||
// ── §9.13 TRACK_STATUS ──
|
||||
c.control(
|
||||
"TRACK_STATUS",
|
||||
ControlMessage::TrackStatus(TrackRequest {
|
||||
request_id: 6,
|
||||
namespace: ns(&["example"]),
|
||||
name: name("cam1"),
|
||||
params: sorted(vec![Parameter::AuthorizationToken(Token::Register { alias: 2, token_type: 0x9d, value: b"abc".to_vec() }), Parameter::IncludeProperties(true)]),
|
||||
}),
|
||||
);
|
||||
// ── §9.14, §9.15, §9.18 namespace requests ──
|
||||
c.control(
|
||||
"PUBLISH_NAMESPACE",
|
||||
ControlMessage::PublishNamespace(NamespaceRequest { request_id: 3, prefix: ns(&["example", "cam1"]), params: sorted(vec![Parameter::AuthorizationToken(Token::UseValue { token_type: 1, value: vec![0xde, 0xad] })]) }),
|
||||
);
|
||||
c.control("SUBSCRIBE_NAMESPACE", ControlMessage::SubscribeNamespace(NamespaceRequest { request_id: 8, prefix: ns(&["example"]), params: Parameters::new() }));
|
||||
c.control(
|
||||
"SUBSCRIBE_TRACKS for every track",
|
||||
ControlMessage::SubscribeTracks(NamespaceRequest {
|
||||
request_id: 10,
|
||||
prefix: TrackNamespace(vec![]),
|
||||
params: sorted(vec![
|
||||
Parameter::Forward(false),
|
||||
Parameter::GroupOrder(GroupOrder::Ascending),
|
||||
rf(param::TRACK_PROPERTY_FILTER, 0, Some(prop::DYNAMIC_GROUPS), &[(1, Some(1))]),
|
||||
Parameter::IncludeProperties(false),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
// ── §9.16, §9.17, §9.19 ──
|
||||
c.control("NAMESPACE", ControlMessage::Namespace(ns(&["42"])));
|
||||
c.control("NAMESPACE_DONE", ControlMessage::NamespaceDone(ns(&["123"])));
|
||||
c.control("PUBLISH_SKIPPED", ControlMessage::PublishSkipped(PublishSkipped { suffix: ns(&["cam2"]), name: name("video") }));
|
||||
// ── §9.5 REQUEST_UPDATE ──
|
||||
c.control(
|
||||
"REQUEST_UPDATE for a subscription",
|
||||
ControlMessage::RequestUpdate(RequestUpdate {
|
||||
request_id: 12,
|
||||
params: sorted(vec![
|
||||
Parameter::Forward(false),
|
||||
Parameter::SubscriberPriority(5),
|
||||
Parameter::LocationFilter(LocationFilter::None),
|
||||
Parameter::RangeFilter { ty: param::OBJECTID_FILTER, filter: None },
|
||||
Parameter::NewGroupRequest(12),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
c.control(
|
||||
"REQUEST_UPDATE for a namespace subscription",
|
||||
ControlMessage::RequestUpdate(RequestUpdate { request_id: 14, params: sorted(vec![Parameter::TrackNamespacePrefix(ns(&["example", "b"]))]) }),
|
||||
);
|
||||
// ── §9.3 REQUEST_OK, §9.4 REQUEST_ERROR ──
|
||||
c.control(
|
||||
"REQUEST_OK answering TRACK_STATUS",
|
||||
ControlMessage::RequestOk(RequestOk { params: sorted(vec![Parameter::LargestObject(Location::new(3, 9))]), track_properties: props(vec![kv(prop::DEFAULT_PUBLISHER_GROUP_ORDER, 2)]) }),
|
||||
);
|
||||
c.control("REQUEST_OK answering PUBLISH", ControlMessage::RequestOk(RequestOk { params: sorted(vec![Parameter::Expires(1000)]), track_properties: Properties::new() }));
|
||||
c.control(
|
||||
"REQUEST_ERROR",
|
||||
ControlMessage::RequestError(RequestError { code: RequestErrorCode::DOES_NOT_EXIST, retry_interval: 0, reason: b"no such track".to_vec(), redirect: None }),
|
||||
);
|
||||
c.control(
|
||||
"REQUEST_ERROR with a redirect",
|
||||
ControlMessage::RequestError(RequestError {
|
||||
code: RequestErrorCode::REDIRECT,
|
||||
retry_interval: 1,
|
||||
reason: vec![],
|
||||
redirect: Some(Redirect { connect_uri: b"moqt://other.example/live".to_vec(), namespace: ns(&["example"]), name: name("cam1") }),
|
||||
}),
|
||||
);
|
||||
c.control(
|
||||
"REQUEST_ERROR with a code this draft does not register",
|
||||
ControlMessage::RequestError(RequestError { code: RequestErrorCode(0x9d), retry_interval: 30_001, reason: b"grease".to_vec(), redirect: None }),
|
||||
);
|
||||
|
||||
// ── §6.3 a control stream: SETUP, then GOAWAY ──
|
||||
let setup = Setup { options: { let mut o = SetupOptions::new(); o.push(kb(opt::MOQT_IMPLEMENTATION, b"example-impl/1.0")); o } };
|
||||
let goaway = ControlMessage::Goaway(Goaway { new_session_uri: vec![], timeout_ms: 1000 });
|
||||
c.stream(
|
||||
"control stream: SETUP then GOAWAY",
|
||||
encode_control_stream(&setup, std::slice::from_ref(&goaway)).unwrap(),
|
||||
GroupOrder::Ascending,
|
||||
vec![UniEvent::Header(StreamHeader::Control), UniEvent::Control(ControlMessage::Setup(setup.clone())), UniEvent::Control(goaway.clone())],
|
||||
);
|
||||
|
||||
// ── §11.3 subgroup streams ──
|
||||
let sg = |h: SubgroupHeader, objs: Vec<SubgroupObject>| -> (Vec<u8>, Vec<UniEvent>) {
|
||||
let b = encode_subgroup_stream(&h, &objs).unwrap();
|
||||
let mut ev = vec![UniEvent::Header(StreamHeader::Subgroup(h))];
|
||||
ev.extend(objs.into_iter().map(UniEvent::SubgroupObject));
|
||||
(b, ev)
|
||||
};
|
||||
let (b, ev) = sg(
|
||||
SubgroupHeader { track_alias: 2, group_id: 0, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true },
|
||||
vec![
|
||||
SubgroupObject::normal(0, b"key".to_vec()),
|
||||
SubgroupObject::normal(1, b"delta".to_vec()),
|
||||
SubgroupObject { object_id: 2, properties: Properties::new(), status: ObjectStatus::EndOfGroup, payload: vec![] },
|
||||
],
|
||||
);
|
||||
c.stream("subgroup: Subgroup ID 0, a priority, End of Group", b, GroupOrder::Ascending, ev);
|
||||
let wai = wai_receipt(0x11, 0, "", vec![], 0x22);
|
||||
let (b, ev) = sg(
|
||||
SubgroupHeader { track_alias: 9, group_id: 7, subgroup_id: SubgroupId::FirstObjectId, publisher_priority: None, properties: true, end_of_group: false, first_object: false },
|
||||
vec![
|
||||
SubgroupObject { object_id: 5, properties: props(vec![kv(prop::PRIOR_OBJECT_ID_GAP, 2), kv(prop::OBJECT_DELIVERY_TIMEOUT, 40)]), status: ObjectStatus::Normal, payload: b"five".to_vec() },
|
||||
SubgroupObject { object_id: 6, properties: Properties::new(), status: ObjectStatus::Normal, payload: vec![] },
|
||||
SubgroupObject {
|
||||
object_id: 8,
|
||||
properties: with_immutable(vec![], vec![kv(0x3a40, 1), kb(0x3a41, &wai.to_bytes()), kv(0x3a42, 3), kv(0x3a44, 1)]),
|
||||
status: ObjectStatus::Normal,
|
||||
payload: b"WAI1".to_vec(),
|
||||
},
|
||||
],
|
||||
);
|
||||
c.stream("subgroup: Subgroup ID from the first object, default priority, properties", b, GroupOrder::Ascending, ev);
|
||||
let (b, ev) = sg(
|
||||
SubgroupHeader { track_alias: 1, group_id: 300, subgroup_id: SubgroupId::Explicit(3), publisher_priority: Some(0), properties: false, end_of_group: false, first_object: true },
|
||||
vec![SubgroupObject::normal(0, b"x".to_vec()), SubgroupObject { object_id: 1, properties: Properties::new(), status: ObjectStatus::EndOfTrack, payload: vec![] }],
|
||||
);
|
||||
c.stream("subgroup: an explicit Subgroup ID, End of Track", b, GroupOrder::Ascending, ev);
|
||||
let h = SubgroupHeader { track_alias: 4, group_id: 1, subgroup_id: SubgroupId::Zero, publisher_priority: Some(9), properties: false, end_of_group: false, first_object: true };
|
||||
let mut hb = Vec::new();
|
||||
h.encode(&mut hb);
|
||||
c.stream("subgroup: a header and no object", hb, GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Subgroup(h))]);
|
||||
|
||||
// ── §11.4 fetch streams ──
|
||||
let fo = |g: u64, o: u64, s: Option<u64>, p: u8, pr: Option<Properties>, pl: &[u8]| FetchEntry::Object(FetchObject { group_id: g, object_id: o, subgroup_id: s, publisher_priority: p, properties: pr, payload: pl.to_vec() });
|
||||
for (order, label, gs) in [(GroupOrder::Ascending, "Ascending", [1u64, 4, 6, 9]), (GroupOrder::Descending, "Descending", [9, 6, 4, 1])] {
|
||||
let entries = vec![
|
||||
fo(gs[0], 0, Some(0), 7, None, b"a"),
|
||||
fo(gs[0], 1, Some(0), 7, None, b"b"),
|
||||
fo(gs[0], 5, Some(1), 9, Some(props(vec![kv(prop::PRIOR_OBJECT_ID_GAP, 3)])), b""),
|
||||
FetchEntry::EndOfRange { kind: RangeEnd::NonExistent, location: Location::new(gs[1], 3) },
|
||||
fo(gs[1], 4, None, 9, None, b"d"),
|
||||
FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(gs[2], 0) },
|
||||
FetchEntry::EndOfRange { kind: RangeEnd::TimedOut, location: Location::new(gs[3], 2) },
|
||||
fo(gs[3], 3, Some(7), 9, None, b"e"),
|
||||
fo(gs[3], 4, Some(8), 2, None, b"f"),
|
||||
];
|
||||
let b = encode_fetch_stream(&FetchHeader { request_id: 4 }, order, &entries).unwrap();
|
||||
let mut ev = vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 4 }))];
|
||||
ev.extend(entries.into_iter().map(UniEvent::FetchEntry));
|
||||
c.stream(&format!("fetch in {label} order, with each End of Range"), b, order, ev);
|
||||
}
|
||||
let first_eor = vec![FetchEntry::EndOfRange { kind: RangeEnd::Unknown, location: Location::new(2, 5) }, fo(2, 6, Some(0), 1, None, b"z")];
|
||||
let b = encode_fetch_stream(&FetchHeader { request_id: 2 }, GroupOrder::Ascending, &first_eor).unwrap();
|
||||
let mut ev = vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 2 }))];
|
||||
ev.extend(first_eor.into_iter().map(UniEvent::FetchEntry));
|
||||
c.stream("fetch beginning with an End of Range", b, GroupOrder::Ascending, ev);
|
||||
let mut fb = Vec::new();
|
||||
FetchHeader { request_id: 10 }.encode(&mut fb);
|
||||
c.stream("fetch with no objects", fb, GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Fetch(FetchHeader { request_id: 10 }))]);
|
||||
|
||||
// ── §11.5 padding ──
|
||||
c.stream("padding stream", encode_padding_stream(8), GroupOrder::Ascending, vec![UniEvent::Header(StreamHeader::Padding), UniEvent::Padding(8)]);
|
||||
|
||||
// ── §11.2 datagrams ──
|
||||
let dg = |d: ObjectDatagram| (Datagram::Object(d.clone()), d.encode().unwrap());
|
||||
for (label, d) in [
|
||||
("datagram: Object ID 0, default priority", ObjectDatagram { track_alias: 2, group_id: 5, object_id: 0, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(b"opus".to_vec()) }),
|
||||
(
|
||||
"datagram: a priority, properties, End of Group",
|
||||
ObjectDatagram { track_alias: 2, group_id: 5, object_id: 9, publisher_priority: Some(3), properties: Some(props(vec![kv(prop::OBJECT_DELIVERY_TIMEOUT, 20)])), end_of_group: true, content: DatagramContent::Payload(b"last".to_vec()) },
|
||||
),
|
||||
("datagram: End of Track status", ObjectDatagram { track_alias: 2, group_id: 6, object_id: 0, publisher_priority: Some(3), properties: None, end_of_group: false, content: DatagramContent::Status(ObjectStatus::EndOfTrack) }),
|
||||
(
|
||||
"datagram: Normal status with properties",
|
||||
ObjectDatagram { track_alias: 3, group_id: 1, object_id: 1, publisher_priority: None, properties: Some(props(vec![kb(0x3a45, b"p")])), end_of_group: false, content: DatagramContent::Status(ObjectStatus::Normal) },
|
||||
),
|
||||
("datagram: an empty payload", ObjectDatagram { track_alias: 3, group_id: 1, object_id: 2, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(vec![]) }),
|
||||
] {
|
||||
let (d, b) = dg(d);
|
||||
c.datagram(label, d, b, true);
|
||||
}
|
||||
// Object ID 0 written out rather than flagged: the same object.
|
||||
let d = ObjectDatagram { track_alias: 2, group_id: 5, object_id: 0, publisher_priority: None, properties: None, end_of_group: false, content: DatagramContent::Payload(b"x".to_vec()) };
|
||||
c.datagram("datagram: Object ID 0 written out", Datagram::Object(d), cat(&[&v(0x08), &v(2), &v(5), &v(0), b"x"]), false);
|
||||
c.datagram("padding datagram", Datagram::Padding(4), Datagram::padding(4), true);
|
||||
|
||||
// ── the WAI_RECEIPT property's value (carriage) ──
|
||||
let mut on_chip = 10_000u32.to_be_bytes().to_vec();
|
||||
on_chip.extend_from_slice(&1_000_000u64.to_be_bytes());
|
||||
on_chip.push(1);
|
||||
let mut calibrated = 2_000u32.to_be_bytes().to_vec();
|
||||
calibrated.extend_from_slice(&250_000u64.to_be_bytes());
|
||||
calibrated.extend_from_slice(&(b"cal-2026-07".len() as u32).to_be_bytes());
|
||||
calibrated.extend_from_slice(b"cal-2026-07");
|
||||
for (label, p) in [
|
||||
("WAI_RECEIPT: unmetered", wai_receipt(0x11, 0, "", vec![], 0x22)),
|
||||
("WAI_RECEIPT: OnChipCounter", wai_receipt(0x33, 1_200_000, "OnChipCounter", on_chip, 0x44)),
|
||||
("WAI_RECEIPT: CalibratedInstrument", wai_receipt(0x55, 4_000_000, "CalibratedInstrument", calibrated, 0x66)),
|
||||
("WAI_RECEIPT: ModelBased", wai_receipt(0x77, 300, "ModelBased", vec![], 0x88)),
|
||||
] {
|
||||
c.push(label, "wai-receipt", &p.to_bytes(), true, None, wai_json(&p));
|
||||
}
|
||||
let mut extra = wai_receipt(0x11, 0, "", vec![], 0x22).to_bytes();
|
||||
extra.push(0);
|
||||
c.bad("WAI_RECEIPT: a byte after the last field", "wai-receipt", &extra, None, "invalid-property", "property_invalid");
|
||||
c.bad("WAI_RECEIPT: cut short", "wai-receipt", &wai_receipt(0x11, 0, "", vec![], 0x22).to_bytes()[..60], None, "invalid-property", "property_invalid");
|
||||
|
||||
// ── negative vectors: control messages ──
|
||||
let good_sub = ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["a"]), name: name("b"), params: sorted(vec![Parameter::Forward(true)]) }).encode().unwrap();
|
||||
c.bad("control: the Length runs past the bytes", "control", &good_sub[..good_sub.len() - 1], None, "truncated", PV);
|
||||
// Length one short: the last parameter is cut inside the body.
|
||||
let mut short = good_sub.clone();
|
||||
short[2] -= 1;
|
||||
short.pop();
|
||||
c.bad("control: a body shorter than its fields", "control", &short, None, "truncated", PV);
|
||||
let body = cat(&[&v(4), &v(0), &[0]]);
|
||||
c.bad("control: a byte after the last field", "control", &frame(msg::REQUEST_UPDATE, &body), None, "trailing-bytes", PV);
|
||||
c.bad("control: a varint that runs past its body", "control", &frame(msg::GOAWAY, &[0x00, 0xff, 0x01]), None, "truncated", PV);
|
||||
c.bad("control: type 0x40, reserved (CLIENT_SETUP up to -10)", "control", &frame(0x40, &[]), None, "unknown-message-type", PV);
|
||||
c.bad("control: type 0x21, reserved (SERVER_SETUP up to -16)", "control", &frame(0x21, &[]), None, "unknown-message-type", PV);
|
||||
c.bad("control: an unknown type", "control", &frame(0x3f, &[]), None, "unknown-message-type", PV);
|
||||
let sub_body = |params: &[u8], n: u64| cat(&[&v(0), &v(1), &v(1), b"a", &v(1), b"b", &v(n), params]);
|
||||
c.bad("SUBSCRIBE: FORWARD twice", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x10, 1, 0x00, 0], 2)), None, "duplicate", PV);
|
||||
c.bad("SUBSCRIBE: one OBJECTID_FILTER key twice", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x26, 2, 0, 1, 0x00, 2, 0, 4], 2)), None, "duplicate-filter", "request:INVALID_FILTER");
|
||||
c.bad("SUBSCRIBE: an unknown parameter", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x05, 0], 1)), None, "unknown-parameter", PV);
|
||||
c.bad("SUBSCRIBE: FILL_PARAMETERS holding FORWARD", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x23, 2, 0x10, 1], 1)), None, "parameter-not-allowed", PV);
|
||||
c.bad("SUBSCRIBE: FORWARD of 2", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x10, 2], 1)), None, "invalid-value", PV);
|
||||
c.bad("SUBSCRIBE: GROUP_ORDER of 0", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x22, 0], 1)), None, "invalid-value", PV);
|
||||
c.bad("SUBSCRIBE: a Location Filter Type of 6", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x21, 6], 1)), None, "invalid-value", PV);
|
||||
let mut eg = vec![0x21, 3];
|
||||
eg.extend(v(u64::MAX));
|
||||
eg.extend(v(0));
|
||||
eg.extend(v(1));
|
||||
c.bad("SUBSCRIBE: StartGroup + EndGroupDelta past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&eg, 1)), None, "overflow", PV);
|
||||
let mut ov = vec![0x35, 1];
|
||||
ov.extend(v(u64::MAX));
|
||||
ov.push(1);
|
||||
c.bad("SUBSCRIBE: a parameter type past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&ov, 2)), None, "type-overflow", PV);
|
||||
c.bad("SUBSCRIBE: a PRIORITY_FILTER above 255", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x27, 4, 0, 0x81, 0x00, 0], 1)), None, "invalid-filter", "request:INVALID_FILTER");
|
||||
c.bad("SUBSCRIBE: an OBJECT_PROPERTY_FILTER on an odd type", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x28, 3, 0, 0x0b, 0], 1)), None, "invalid-filter", "request:INVALID_FILTER");
|
||||
let mut rd = vec![0x26];
|
||||
let inner = cat(&[&[0], &v(5), &v(u64::MAX)]);
|
||||
rd.extend(v(inner.len() as u64));
|
||||
rd.extend(inner);
|
||||
c.bad("SUBSCRIBE: a range end past 2^64-1", "control", &frame(msg::SUBSCRIBE, &sub_body(&rd, 1)), None, "invalid-filter", "request:INVALID_FILTER");
|
||||
c.bad("SUBSCRIBE: a token of an unknown alias type", "control", &frame(msg::SUBSCRIBE, &sub_body(&[0x03, 2, 0x04, 0x00], 1)), None, "malformed-token", "session:KEY_VALUE_FORMATTING_ERROR");
|
||||
c.bad("SUBSCRIBE_OK: a parameter it may not carry (FORWARD)", "control", &frame(msg::SUBSCRIBE_OK, &[7, 1, 0x10, 1]), None, "parameter-not-allowed", PV);
|
||||
let mut many = v(33);
|
||||
for _ in 0..33 {
|
||||
many.extend(cat(&[&v(1), b"x"]));
|
||||
}
|
||||
c.bad("SUBSCRIBE_NAMESPACE: 33 namespace fields", "control", &frame(msg::SUBSCRIBE_NAMESPACE, &cat(&[&v(0), &many, &v(0)])), None, "too-many-namespace-fields", PV);
|
||||
c.bad("SUBSCRIBE_NAMESPACE: an empty namespace field", "control", &frame(msg::SUBSCRIBE_NAMESPACE, &cat(&[&v(0), &v(1), &v(0), &v(0)])), None, "empty-namespace-field", PV);
|
||||
let long_field = vec![b'n'; 4000];
|
||||
let fb = cat(&[&v(0), &v(1), &v(4000), &long_field, &v(200)]);
|
||||
c.bad("SUBSCRIBE: a full track name past 4,096 bytes", "control", &frame(msg::SUBSCRIBE, &fb), None, "length-exceeds-limit", PV);
|
||||
let reason = vec![b'r'; 1025];
|
||||
c.bad("REQUEST_ERROR: a reason phrase of 1,025 bytes", "control", &frame(msg::REQUEST_ERROR, &cat(&[&v(0), &v(0), &v(1025), &reason])), None, "length-exceeds-limit", PV);
|
||||
c.bad("GOAWAY: a URI of 8,193 bytes", "control", &frame(msg::GOAWAY, &v(8193)), None, "length-exceeds-limit", PV);
|
||||
c.bad("SUBSCRIBE_OK: a property value of 65,536 bytes", "control", &frame(msg::SUBSCRIBE_OK, &cat(&[&v(1), &v(0), &v(0x3a45), &v(0x1_0000)])), None, "length-exceeds-limit", PV);
|
||||
c.bad("FETCH_OK: End Of Track of 2", "control", &frame(msg::FETCH_OK, &[2, 0, 0, 0]), None, "invalid-value", PV);
|
||||
c.bad("REQUEST_ERROR: REDIRECT cut short", "control", &frame(msg::REQUEST_ERROR, &cat(&[&v(0x34), &v(0), &v(0), &v(0)])), None, "truncated", PV);
|
||||
c.bad("SETUP: MAX_FILTER_RANGES twice", "control", &frame(msg::SETUP, &[0x06, 1, 0x00, 2]), None, "duplicate", PV);
|
||||
let mut sd = v(u64::MAX);
|
||||
sd.extend(v(0));
|
||||
sd.extend(v(1));
|
||||
sd.extend(v(0));
|
||||
c.bad("SETUP: an option type past 2^64-1", "control", &frame(msg::SETUP, &sd), None, "type-overflow", PV);
|
||||
c.bad("SETUP: a token that does not read", "control", &frame(msg::SETUP, &[0x03, 1, 0x07]), None, "malformed-token", "session:KEY_VALUE_FORMATTING_ERROR");
|
||||
c.bad("SUBSCRIBE_OK: Immutable Properties inside Immutable Properties", "control", &frame(msg::SUBSCRIBE_OK, &[1, 0, 0x0b, 3, 0x0b, 1, 0x00]), None, "malformed-track", "malformed-track");
|
||||
c.bad("SUBSCRIBE_OK: DYNAMIC_GROUPS of 2", "control", &frame(msg::SUBSCRIBE_OK, &[1, 0, 0x30, 2]), None, "invalid-value", PV);
|
||||
|
||||
// ── negative vectors: streams and datagrams ──
|
||||
let asc = Some(json!({"group_order": 1}));
|
||||
let setup_bytes = ControlMessage::Setup(setup.clone()).encode().unwrap();
|
||||
let g = goaway.encode().unwrap();
|
||||
c.bad("control stream: GOAWAY twice", "uni-stream", &cat(&[&setup_bytes, &g, &g]), asc.clone(), "unexpected-message", PV);
|
||||
let sub_msg = ControlMessage::Subscribe(TrackRequest { request_id: 0, namespace: ns(&["a"]), name: name("b"), params: Parameters::new() }).encode().unwrap();
|
||||
c.bad("control stream: SUBSCRIBE after SETUP", "uni-stream", &cat(&[&setup_bytes, &sub_msg]), asc.clone(), "unexpected-message", PV);
|
||||
c.bad("stream: type 0x06", "uni-stream", &[0x06, 0], asc.clone(), "unknown-stream-type", PV);
|
||||
c.bad("stream: a subgroup type of 0x91, two bytes", "uni-stream", &[0x80, 0x91, 0, 0, 0], asc.clone(), "unknown-stream-type", PV);
|
||||
c.bad("subgroup: SUBGROUP_ID_MODE 0b11", "uni-stream", &[0x16, 0, 0, 0, 0], asc.clone(), "invalid-flags", PV);
|
||||
let sgh = |flags: u8| vec![flags, 1, 2, 7];
|
||||
c.bad("subgroup: Object Status 0x1", "uni-stream", &cat(&[&sgh(0x10), &[0, 0, 1]]), asc.clone(), "unknown-object-status", PV);
|
||||
c.bad("subgroup: properties on End of Group", "uni-stream", &cat(&[&sgh(0x11), &[0, 2, 0x02, 5, 0, 3]]), asc.clone(), "properties-on-non-normal", PV);
|
||||
c.bad("subgroup: a FIN inside an object", "uni-stream", &cat(&[&sgh(0x10), &[0, 5, b'a', b'b']]), asc.clone(), "truncated", PV);
|
||||
let mut big = sgh(0x10);
|
||||
big.extend(v(u64::MAX));
|
||||
big.extend(&[1, b'a', 0, 1, b'b']);
|
||||
c.bad("subgroup: an Object ID past 2^64-1", "uni-stream", &big, asc.clone(), "overflow", PV);
|
||||
c.bad("subgroup: Immutable Properties inside Immutable Properties", "uni-stream", &cat(&[&sgh(0x11), &[0, 5, 0x0b, 3, 0x0b, 1, 0x00, 1, b'x']]), asc.clone(), "malformed-track", "malformed-track");
|
||||
let mut mand = sgh(0x11);
|
||||
mand.extend(&[0]);
|
||||
let mp = cat(&[&v(0x4000), &v(1)]);
|
||||
mand.extend(v(mp.len() as u64));
|
||||
mand.extend(mp);
|
||||
mand.extend(&[1, b'x']);
|
||||
c.bad("subgroup: a Mandatory Track Property on an object", "uni-stream", &mand, asc.clone(), "malformed-track", "malformed-track");
|
||||
c.bad("subgroup: a Prior Object ID Gap past the Object ID", "uni-stream", &cat(&[&sgh(0x11), &[1, 2, 0x3e, 3, 1, b'x']]), asc.clone(), "malformed-track", "malformed-track");
|
||||
let mut fh = Vec::new();
|
||||
FetchHeader { request_id: 0 }.encode(&mut fh);
|
||||
c.bad("fetch: a first object that refers back", "uni-stream", &cat(&[&fh, &[0x1d, 0, 0, 7, 1, b'a']]), asc.clone(), "no-prior-object", PV);
|
||||
c.bad("fetch: Serialization Flags of 0x80", "uni-stream", &cat(&[&fh, &v(0x80), &[0, 0]]), asc.clone(), "invalid-flags", PV);
|
||||
c.bad("fetch: a Group ID below 0 in Descending order", "uni-stream", &cat(&[&fh, &[0x1c, 1, 0, 7, 1, b'a', 0x08, 1, 0, 1, b'b']]), Some(json!({"group_order": 2})), "overflow", PV);
|
||||
c.bad("fetch: a FIN inside an object", "uni-stream", &cat(&[&fh, &[0x1c, 1, 0, 7, 3, b'a']]), asc.clone(), "truncated", PV);
|
||||
c.bad("datagram: STATUS and END_OF_GROUP", "datagram", &[0x22, 1, 2, 3, 0], None, "invalid-flags", PV);
|
||||
c.bad("datagram: bit 4 set", "datagram", &[0x10, 1, 2, 3, b'x'], None, "invalid-flags", PV);
|
||||
c.bad("datagram: an undefined bit (0x40)", "datagram", &[0x40, 1, 2, 3, b'x'], None, "invalid-flags", PV);
|
||||
c.bad("datagram: PROPERTIES with a length of 0", "datagram", &[0x0d, 1, 2, 0, b'x'], None, "empty-datagram-properties", PV);
|
||||
c.bad("datagram: properties beside End of Track", "datagram", &[0x2d, 1, 2, 2, 0x02, 5, 4], None, "properties-on-non-normal", PV);
|
||||
c.bad("datagram: an unknown status", "datagram", &[0x2c, 1, 2, 2], None, "unknown-object-status", PV);
|
||||
c.bad("datagram: a byte after the status", "datagram", &[0x2c, 1, 2, 0, 0], None, "trailing-bytes", PV);
|
||||
c.bad("datagram: cut short", "datagram", &[0x00, 1], None, "truncated", PV);
|
||||
|
||||
c.vectors
|
||||
}
|
||||
|
||||
fn corpus_doc(vectors: Vec<Value>) -> Value {
|
||||
json!({
|
||||
"about": "draft-ietf-moq-transport-22 wire vectors: varints, control messages, unidirectional streams (control, subgroup, fetch, padding), datagrams, and the value of the WAI_RECEIPT object property of this crate's carriage. Each vector gives its bytes in hex and either its reading, in the JSON view of joule_moq::draft22::json described in the crate README, or the refusal it must meet with the draft's consequence. A uni-stream vector is the stream's bytes up to its FIN; its context gives the Group Order of the request a fetch stream answers (1 Ascending, 2 Descending). A vector marked canonical is the form this crate's encoder makes. Generated by tests/draft22_vectors.rs; regenerate with JOULE_MOQ_WRITE_VECTORS=1.",
|
||||
"draft": "draft-ietf-moq-transport-22",
|
||||
"vectors": vectors,
|
||||
})
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn order_of(v: &Value) -> GroupOrder {
|
||||
match v["context"]["group_order"].as_u64() {
|
||||
Some(2) => GroupOrder::Descending,
|
||||
_ => GroupOrder::Ascending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a vector: its reading, or its refusal.
|
||||
fn decode(v: &Value) -> Result<(Value, Option<Vec<u8>>), Value> {
|
||||
let b = unhex(v["hex"].as_str().unwrap());
|
||||
let err = |e: WireError| json::error(&e);
|
||||
match v["kind"].as_str().unwrap() {
|
||||
"varint" => {
|
||||
let (val, n) = varint::get(&b, "varint").map_err(err)?;
|
||||
if n != b.len() {
|
||||
return Err(json!({"error": "trailing-bytes"}));
|
||||
}
|
||||
Ok((json!({"value": val, "length": n}), Some(varint::to_vec(val))))
|
||||
}
|
||||
"control" => {
|
||||
let m = ControlMessage::decode_exact(&b).map_err(err)?;
|
||||
Ok((json::control(&m), Some(m.encode().unwrap())))
|
||||
}
|
||||
"uni-stream" => {
|
||||
let order = order_of(v);
|
||||
let ev = decode_uni_stream(&b, order).map_err(err)?;
|
||||
Ok((Value::Array(ev.iter().map(json::uni_event).collect()), reencode(&ev, order)))
|
||||
}
|
||||
"datagram" => {
|
||||
let d = Datagram::decode(&b).map_err(err)?;
|
||||
let again = match &d {
|
||||
Datagram::Object(o) => o.encode().unwrap(),
|
||||
Datagram::Padding(n) => Datagram::padding(*n),
|
||||
};
|
||||
Ok((json::datagram(&d), Some(again)))
|
||||
}
|
||||
"wai-receipt" => match WaiReceiptProperty::from_bytes(&b) {
|
||||
Ok(p) => Ok((wai_json(&p), Some(p.to_bytes()))),
|
||||
Err(_) => Err(json!({"error": "invalid-property", "consequence": "property_invalid"})),
|
||||
},
|
||||
k => panic!("unknown kind {k}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes this crate's encoder makes of a decoded stream.
|
||||
fn reencode(ev: &[UniEvent], order: GroupOrder) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut sub: Option<SubgroupCodec> = None;
|
||||
let mut fetch: Option<FetchCodec> = None;
|
||||
for e in ev {
|
||||
match e {
|
||||
UniEvent::Header(StreamHeader::Control) => {}
|
||||
UniEvent::Header(StreamHeader::Padding) => out.extend(varint::to_vec(joule_moq::draft22::data::stream_type::PADDING)),
|
||||
UniEvent::Header(StreamHeader::Subgroup(h)) => {
|
||||
h.encode(&mut out);
|
||||
sub = Some(SubgroupCodec::new(*h));
|
||||
}
|
||||
UniEvent::Header(StreamHeader::Fetch(h)) => {
|
||||
h.encode(&mut out);
|
||||
fetch = Some(FetchCodec::new(order));
|
||||
}
|
||||
UniEvent::Control(m) => out.extend(m.encode().ok()?),
|
||||
UniEvent::SubgroupObject(o) => sub.as_mut()?.encode_object(o, &mut out).ok()?,
|
||||
UniEvent::FetchEntry(f) => fetch.as_mut()?.encode(f, &mut out).ok()?,
|
||||
UniEvent::Padding(n) => out.resize(out.len() + n, 0),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The incremental decoder over the same bytes, fed in pieces of `step`.
|
||||
fn decode_incrementally(b: &[u8], order: GroupOrder, step: usize) -> Result<Vec<UniEvent>, WireError> {
|
||||
let mut d = UniStreamDecoder::new(order, Limits { max_payload: u64::MAX, max_properties: u64::MAX });
|
||||
let mut out = Vec::new();
|
||||
for chunk in b.chunks(step.max(1)) {
|
||||
d.push(chunk);
|
||||
while let Some(e) = d.next()? {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
d.finish()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_draft22_corpus_is_current_and_holds() {
|
||||
let doc = corpus_doc(build());
|
||||
let text = serde_json::to_string_pretty(&doc).unwrap() + "\n";
|
||||
if std::env::var("JOULE_MOQ_WRITE_VECTORS").as_deref() == Ok("1") {
|
||||
std::fs::create_dir_all(std::path::Path::new(CORPUS).parent().unwrap()).unwrap();
|
||||
std::fs::write(CORPUS, &text).unwrap();
|
||||
}
|
||||
let committed = std::fs::read_to_string(CORPUS).unwrap_or_else(|e| panic!("read {CORPUS}: {e}"));
|
||||
assert!(committed == text, "{CORPUS} is not what tests/draft22_vectors.rs generates; regenerate it with JOULE_MOQ_WRITE_VECTORS=1");
|
||||
|
||||
let mut failures = Vec::new();
|
||||
let (mut good, mut bad) = (0, 0);
|
||||
for v in doc["vectors"].as_array().unwrap() {
|
||||
let name = v["name"].as_str().unwrap();
|
||||
let expect = &v["expect"];
|
||||
let b = unhex(v["hex"].as_str().unwrap());
|
||||
match (expect.get("error"), decode(v)) {
|
||||
(None, Ok((got, again))) => {
|
||||
good += 1;
|
||||
if &got != expect {
|
||||
failures.push(format!("{name}: read as {got}"));
|
||||
}
|
||||
if v["canonical"] == true && again.as_deref() != Some(&b[..]) {
|
||||
failures.push(format!("{name}: re-encodes to other bytes"));
|
||||
}
|
||||
}
|
||||
(None, Err(e)) => failures.push(format!("{name}: refused ({e}); the corpus reads it")),
|
||||
(Some(_), Ok((got, _))) => failures.push(format!("{name}: read as {got}; the corpus refuses it")),
|
||||
(Some(_), Err(e)) => {
|
||||
bad += 1;
|
||||
if &e != expect {
|
||||
failures.push(format!("{name}: refused as {e}, the corpus says {expect}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The incremental decoder agrees on every stream: the same events for a good
|
||||
// one, a refusal for a negative one, whatever the pieces.
|
||||
if v["kind"] == "uni-stream" {
|
||||
for step in [1, 2, 7, b.len().max(1)] {
|
||||
let inc = decode_incrementally(&b, order_of(v), step);
|
||||
match (expect.get("error"), inc) {
|
||||
(None, Ok(ev)) => {
|
||||
// Padding arrives in whatever pieces the bytes did; count it whole.
|
||||
let mut merged: Vec<UniEvent> = Vec::new();
|
||||
for e in ev {
|
||||
match (merged.last_mut(), e) {
|
||||
(Some(UniEvent::Padding(n)), UniEvent::Padding(k)) => *n += k,
|
||||
(_, e) => merged.push(e),
|
||||
}
|
||||
}
|
||||
let got = Value::Array(merged.iter().map(json::uni_event).collect());
|
||||
if &got != expect {
|
||||
failures.push(format!("{name}: in pieces of {step}, read as {got}"));
|
||||
}
|
||||
}
|
||||
(None, Err(e)) => failures.push(format!("{name}: in pieces of {step}, refused ({e})")),
|
||||
(Some(_), Ok(_)) => failures.push(format!("{name}: in pieces of {step}, accepted")),
|
||||
(Some(_), Err(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} vector(s) disagree:\n{}", failures.len(), failures.join("\n"));
|
||||
assert!(good > 0 && bad > 0, "the corpus holds both kinds");
|
||||
}
|
||||
763
joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py
Executable file
763
joule-code/joule-code-rs/crates/joule-moq/tests/vectors/draft-22/moqt22_check.py
Executable file
|
|
@ -0,0 +1,763 @@
|
|||
#!/usr/bin/env python3
|
||||
"""moqt22_check.py: an independent decoder of draft-ietf-moq-transport-22, run over
|
||||
the vector corpus beside it (vectors.json).
|
||||
|
||||
Written from the draft's text, section by section, and not from the Rust codec it
|
||||
checks. For every vector it decodes the bytes itself and compares:
|
||||
|
||||
- a good vector: its reading, in the corpus's JSON view (the crate README
|
||||
describes it), must equal the vector's stated reading;
|
||||
- a negative vector: the decoder must refuse it, with the stated kind of
|
||||
refusal and the draft's consequence.
|
||||
|
||||
Where -22's text leaves an encoding open this decoder takes the readings the
|
||||
crate README lists: FILL_TIMEOUT (0x0A) and RENDEZVOUS_TIMEOUT (0x04) are varints;
|
||||
FILL_PARAMETERS holds parameters up to its length, with no count; an End of
|
||||
Range's Group ID and Object ID are the delta fields its flag bits 0x08 and 0x04
|
||||
mark; an unknown message, stream or datagram type closes the session with
|
||||
PROTOCOL_VIOLATION.
|
||||
|
||||
python3 -I moqt22_check.py [vectors.json]
|
||||
|
||||
Exit 0 when every vector agrees, 1 otherwise (and when the corpus is empty).
|
||||
Standard library only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
U64 = (1 << 64) - 1
|
||||
PV = "session:PROTOCOL_VIOLATION"
|
||||
KVF = "session:KEY_VALUE_FORMATTING_ERROR"
|
||||
INVALID_FILTER = "request:INVALID_FILTER"
|
||||
MALFORMED = "malformed-track"
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
def __init__(self, kind, consequence=PV):
|
||||
super().__init__(f"{kind} ({consequence})")
|
||||
self.kind = kind
|
||||
self.consequence = consequence
|
||||
|
||||
|
||||
class Buf:
|
||||
"""A cursor that refuses to read past its end (§8: every field is bounded)."""
|
||||
|
||||
def __init__(self, b):
|
||||
self.b = bytes(b)
|
||||
self.i = 0
|
||||
|
||||
def left(self):
|
||||
return len(self.b) - self.i
|
||||
|
||||
def empty(self):
|
||||
return self.left() == 0
|
||||
|
||||
def vi(self):
|
||||
"""§8.1: the count of leading 1 bits of the first byte gives the length,
|
||||
1 to 9 bytes; the bits after the first 0 and the bytes after it are the
|
||||
value, big-endian. Longer forms than needed are valid."""
|
||||
if self.empty():
|
||||
raise Refused("truncated")
|
||||
first = self.b[self.i]
|
||||
ones = 0
|
||||
while ones < 8 and first & (0x80 >> ones):
|
||||
ones += 1
|
||||
length = 9 if ones == 8 else ones + 1
|
||||
if self.left() < length:
|
||||
raise Refused("truncated")
|
||||
if length == 9:
|
||||
v = int.from_bytes(self.b[self.i + 1:self.i + 9], "big")
|
||||
else:
|
||||
v = first & (0xFF >> length)
|
||||
for k in range(1, length):
|
||||
v = (v << 8) | self.b[self.i + k]
|
||||
self.i += length
|
||||
return v
|
||||
|
||||
def u8(self):
|
||||
if self.empty():
|
||||
raise Refused("truncated")
|
||||
v = self.b[self.i]
|
||||
self.i += 1
|
||||
return v
|
||||
|
||||
def u16(self):
|
||||
return (self.u8() << 8) | self.u8()
|
||||
|
||||
def take(self, n):
|
||||
if n > self.left():
|
||||
raise Refused("truncated")
|
||||
s = self.b[self.i:self.i + n]
|
||||
self.i += n
|
||||
return s
|
||||
|
||||
def rest(self):
|
||||
return self.take(self.left())
|
||||
|
||||
def lp(self, limit=None):
|
||||
"""A varint length, then that many bytes; a length past `limit` is refused
|
||||
before any byte behind it is read."""
|
||||
n = self.vi()
|
||||
if limit is not None and n > limit:
|
||||
raise Refused("length-exceeds-limit")
|
||||
return self.take(n)
|
||||
|
||||
def done(self):
|
||||
if not self.empty():
|
||||
raise Refused("trailing-bytes")
|
||||
|
||||
|
||||
def hx(b):
|
||||
return bytes(b).hex()
|
||||
|
||||
|
||||
# §8.3 Key-Value-Pairs ---------------------------------------------------------
|
||||
|
||||
def kvps(b):
|
||||
"""Pairs until `b` ends: Delta Type; for an odd type a Length of at most
|
||||
2^16-1 and that many bytes, for an even type one varint."""
|
||||
r = Buf(b)
|
||||
out = []
|
||||
prev = 0
|
||||
while not r.empty():
|
||||
t = prev + r.vi()
|
||||
if t > U64:
|
||||
raise Refused("type-overflow")
|
||||
prev = t
|
||||
if t % 2:
|
||||
out.append({"type": t, "bytes": hx(r.lp(0xFFFF))})
|
||||
else:
|
||||
out.append({"type": t, "value": r.vi()})
|
||||
return out
|
||||
|
||||
|
||||
IMMUTABLE = 0x0B
|
||||
|
||||
|
||||
def immutable_of(pairs):
|
||||
"""§10.7: the contents of Immutable Properties; a list that cannot be parsed
|
||||
makes the track malformed."""
|
||||
for p in pairs:
|
||||
if p["type"] == IMMUTABLE and "bytes" in p:
|
||||
try:
|
||||
return kvps(bytes.fromhex(p["bytes"]))
|
||||
except Refused:
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
return None
|
||||
|
||||
|
||||
def flattened(pairs):
|
||||
inner = immutable_of(pairs) or []
|
||||
return [p for p in pairs if p["type"] != IMMUTABLE] + inner
|
||||
|
||||
|
||||
def check_object_properties(pairs, group, obj):
|
||||
"""§3.7, §10.7-10.9: conditions that make a track malformed, read on one object."""
|
||||
if sum(1 for p in pairs if p["type"] == IMMUTABLE) > 1:
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
inner = immutable_of(pairs)
|
||||
if inner is not None and any(p["type"] == IMMUTABLE for p in inner):
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
allp = flattened(pairs)
|
||||
if any(0x4000 <= p["type"] <= 0x7FFF for p in allp):
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
for t, ident in ((0x3C, group), (0x3E, obj)):
|
||||
gaps = [p["value"] for p in allp if p["type"] == t and "value" in p]
|
||||
if len(gaps) > 1 or (gaps and gaps[0] > ident):
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
|
||||
|
||||
def check_track_properties(pairs):
|
||||
"""§10.4-10.7 on Track Properties."""
|
||||
inner = immutable_of(pairs)
|
||||
if inner is not None and any(p["type"] == IMMUTABLE for p in inner):
|
||||
raise Refused("malformed-track", MALFORMED)
|
||||
for p in flattened(pairs):
|
||||
v = p.get("value")
|
||||
if v is None:
|
||||
continue
|
||||
if (p["type"] == 0x0E and v > 255) or (p["type"] == 0x22 and v not in (1, 2)) or (p["type"] == 0x30 and v > 1):
|
||||
raise Refused("invalid-value")
|
||||
|
||||
|
||||
# §8.7 Track Namespaces and names ----------------------------------------------
|
||||
|
||||
def namespace(r):
|
||||
n = r.vi()
|
||||
if n > 32:
|
||||
raise Refused("too-many-namespace-fields")
|
||||
fields = []
|
||||
total = 0
|
||||
for _ in range(n):
|
||||
ln = r.vi()
|
||||
if ln == 0:
|
||||
raise Refused("empty-namespace-field")
|
||||
total += ln
|
||||
if total > 4096:
|
||||
raise Refused("length-exceeds-limit")
|
||||
fields.append(r.take(ln))
|
||||
return fields
|
||||
|
||||
|
||||
def track_name(r, ns_fields):
|
||||
ln = r.vi()
|
||||
if sum(len(f) for f in ns_fields) + ln > 4096:
|
||||
raise Refused("length-exceeds-limit")
|
||||
return r.take(ln)
|
||||
|
||||
|
||||
def ns_json(fields):
|
||||
return [hx(f) for f in fields]
|
||||
|
||||
|
||||
# §8.9 Authorization Token ------------------------------------------------------
|
||||
|
||||
def token(b):
|
||||
try:
|
||||
r = Buf(b)
|
||||
at = r.vi()
|
||||
if at == 0:
|
||||
t = {"alias_type": 0, "alias": r.vi()}
|
||||
elif at == 1:
|
||||
t = {"alias_type": 1, "alias": r.vi(), "token_type": r.vi()}
|
||||
t["value"] = hx(r.rest())
|
||||
elif at == 2:
|
||||
t = {"alias_type": 2, "alias": r.vi()}
|
||||
elif at == 3:
|
||||
t = {"alias_type": 3, "token_type": r.vi()}
|
||||
t["value"] = hx(r.rest())
|
||||
else:
|
||||
raise Refused("x")
|
||||
r.done()
|
||||
return t
|
||||
except Refused:
|
||||
raise Refused("malformed-token", KVF)
|
||||
|
||||
|
||||
# §9.20 Message Parameters ------------------------------------------------------
|
||||
|
||||
NAMES = {
|
||||
0x02: "OBJECT_DELIVERY_TIMEOUT", 0x03: "AUTHORIZATION_TOKEN", 0x04: "RENDEZVOUS_TIMEOUT",
|
||||
0x06: "SUBGROUP_DELIVERY_TIMEOUT", 0x08: "EXPIRES", 0x09: "LARGEST_OBJECT", 0x0A: "FILL_TIMEOUT",
|
||||
0x10: "FORWARD", 0x20: "SUBSCRIBER_PRIORITY", 0x21: "LOCATION_FILTER", 0x22: "GROUP_ORDER",
|
||||
0x23: "FILL_PARAMETERS", 0x25: "SUBGROUP_FILTER", 0x26: "OBJECTID_FILTER", 0x27: "PRIORITY_FILTER",
|
||||
0x28: "OBJECT_PROPERTY_FILTER", 0x29: "TRACK_PROPERTY_FILTER", 0x32: "NEW_GROUP_REQUEST",
|
||||
0x34: "TRACK_NAMESPACE_PREFIX", 0x35: "INCLUDE_PROPERTIES",
|
||||
}
|
||||
|
||||
# Which parameters each message lists (§9.3-9.18). REQUEST_UPDATE and REQUEST_OK
|
||||
# are read before the request they concern is known: the union of their lists.
|
||||
ALLOWED = {
|
||||
"SUBSCRIBE": {0x02, 0x03, 0x04, 0x06, 0x10, 0x20, 0x21, 0x22, 0x23, 0x25, 0x26, 0x27, 0x28, 0x32, 0x35},
|
||||
"SUBSCRIBE_OK": {0x08, 0x09},
|
||||
"PUBLISH": {0x02, 0x03, 0x06, 0x08, 0x09, 0x10, 0x20, 0x21, 0x22},
|
||||
"PUBLISH_STATE_NOTIFY": {0x09, 0x10, 0x21},
|
||||
"FETCH": {0x03, 0x0A, 0x20, 0x21, 0x22, 0x25, 0x26, 0x27, 0x28, 0x35},
|
||||
"FETCH_OK": set(),
|
||||
"TRACK_STATUS": {0x03, 0x35},
|
||||
"PUBLISH_NAMESPACE": {0x03},
|
||||
"SUBSCRIBE_NAMESPACE": {0x03},
|
||||
"SUBSCRIBE_TRACKS": {0x03, 0x10, 0x22, 0x25, 0x26, 0x27, 0x28, 0x29, 0x35},
|
||||
"REQUEST_UPDATE": {0x02, 0x03, 0x06, 0x10, 0x20, 0x21, 0x23, 0x25, 0x26, 0x27, 0x28, 0x29, 0x32, 0x34},
|
||||
"REQUEST_OK": {0x08, 0x09},
|
||||
"FILL_PARAMETERS": {0x0A, 0x20, 0x21, 0x22, 0x25, 0x26, 0x27, 0x28},
|
||||
}
|
||||
|
||||
|
||||
def location_filter(r):
|
||||
"""§9.20.9: the Location Filter Type says which fields follow."""
|
||||
t = r.vi()
|
||||
f = {"filter_type": t}
|
||||
if t in (0, 5):
|
||||
pass
|
||||
elif t == 1:
|
||||
f["start_group"] = r.vi()
|
||||
elif t in (2, 3, 4):
|
||||
f["start_group"] = r.vi()
|
||||
f["start_object"] = r.vi()
|
||||
if t >= 3:
|
||||
f["end_group_delta"] = r.vi()
|
||||
if t == 4:
|
||||
f["end_object"] = r.vi()
|
||||
if t >= 3 and f["start_group"] + f["end_group_delta"] > U64:
|
||||
raise Refused("overflow")
|
||||
else:
|
||||
raise Refused("invalid-value")
|
||||
return f
|
||||
|
||||
|
||||
def range_filter(t, b):
|
||||
"""§8.6 and §9.20.10-14. Length 0: no filter."""
|
||||
if not b:
|
||||
return None
|
||||
r = Buf(b)
|
||||
f = {"set_id": r.u8()}
|
||||
if t in (0x28, 0x29):
|
||||
pt = r.vi()
|
||||
if pt % 2:
|
||||
raise Refused("invalid-filter", INVALID_FILTER)
|
||||
f["property_type"] = pt
|
||||
ranges = []
|
||||
prev_end = 0
|
||||
while not r.empty():
|
||||
start = prev_end + r.vi()
|
||||
if start > U64:
|
||||
raise Refused("invalid-filter", INVALID_FILTER)
|
||||
end = None
|
||||
if not r.empty():
|
||||
end = start + r.vi()
|
||||
if end > U64:
|
||||
raise Refused("invalid-filter", INVALID_FILTER)
|
||||
prev_end = end
|
||||
ranges.append([start, end])
|
||||
if t == 0x27 and any(s > 255 or (e is not None and e > 255) for s, e in ranges):
|
||||
raise Refused("invalid-filter", INVALID_FILTER)
|
||||
f["ranges"] = ranges
|
||||
return f
|
||||
|
||||
|
||||
def flag(r):
|
||||
v = r.u8()
|
||||
if v not in (0, 1):
|
||||
raise Refused("invalid-value")
|
||||
return v
|
||||
|
||||
|
||||
def param_value(t, r):
|
||||
name = NAMES[t]
|
||||
p = {"type": name}
|
||||
if t in (0x02, 0x04, 0x06, 0x08, 0x0A, 0x32):
|
||||
p["value"] = r.vi()
|
||||
elif t == 0x03:
|
||||
p["token"] = token(r.lp())
|
||||
elif t == 0x09:
|
||||
p["location"] = [r.vi(), r.vi()]
|
||||
elif t in (0x10, 0x35):
|
||||
p["value"] = flag(r)
|
||||
elif t == 0x20:
|
||||
p["value"] = r.u8()
|
||||
elif t == 0x21:
|
||||
p["filter"] = location_filter(r)
|
||||
elif t == 0x22:
|
||||
v = r.u8()
|
||||
if v not in (1, 2):
|
||||
raise Refused("invalid-value")
|
||||
p["value"] = v
|
||||
elif t == 0x23:
|
||||
p["parameters"] = parameters(Buf(r.lp()), "FILL_PARAMETERS", counted=False)
|
||||
elif 0x25 <= t <= 0x29:
|
||||
p["filter"] = range_filter(t, r.lp())
|
||||
elif t == 0x34:
|
||||
p["namespace"] = ns_json(namespace(r))
|
||||
return p
|
||||
|
||||
|
||||
def parameters(r, context, counted=True):
|
||||
"""§9.20: Type Delta then a value whose form the type gives. Every parameter
|
||||
must be one this draft defines and one the message lists; one that may not
|
||||
repeat appears once, and a Range Filter's (type, SetID, property type) once."""
|
||||
out = []
|
||||
keys = set()
|
||||
prev = 0
|
||||
n = r.vi() if counted else None
|
||||
i = 0
|
||||
while (i < n) if counted else (not r.empty()):
|
||||
i += 1
|
||||
t = prev + r.vi()
|
||||
if t > U64:
|
||||
raise Refused("type-overflow")
|
||||
prev = t
|
||||
if t not in NAMES:
|
||||
raise Refused("unknown-parameter")
|
||||
if t not in ALLOWED[context]:
|
||||
raise Refused("parameter-not-allowed")
|
||||
p = param_value(t, r)
|
||||
if 0x25 <= t <= 0x29:
|
||||
f = p["filter"]
|
||||
key = (t, None if f is None else f["set_id"], None if f is None else f.get("property_type"))
|
||||
if key in keys:
|
||||
raise Refused("duplicate-filter", INVALID_FILTER)
|
||||
keys.add(key)
|
||||
elif t != 0x03:
|
||||
if t in keys:
|
||||
raise Refused("duplicate")
|
||||
keys.add(t)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# §9 control messages -----------------------------------------------------------
|
||||
|
||||
TYPES = {
|
||||
0x02: "REQUEST_UPDATE", 0x03: "SUBSCRIBE", 0x04: "SUBSCRIBE_OK", 0x05: "REQUEST_ERROR",
|
||||
0x06: "PUBLISH_NAMESPACE", 0x07: "REQUEST_OK", 0x08: "NAMESPACE", 0x0B: "PUBLISH_DONE",
|
||||
0x0D: "TRACK_STATUS", 0x0E: "NAMESPACE_DONE", 0x0F: "PUBLISH_SKIPPED", 0x10: "GOAWAY",
|
||||
0x16: "FETCH", 0x18: "FETCH_OK", 0x1D: "PUBLISH", 0x22: "PUBLISH_STATE_NOTIFY",
|
||||
0x50: "SUBSCRIBE_NAMESPACE", 0x51: "SUBSCRIBE_TRACKS", 0x2F00: "SETUP",
|
||||
}
|
||||
|
||||
KNOWN_OPTIONS = [0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
|
||||
|
||||
|
||||
def track_props(r):
|
||||
pairs = kvps(r.rest())
|
||||
check_track_properties(pairs)
|
||||
return pairs
|
||||
|
||||
|
||||
def setup_body(r):
|
||||
"""§9.1: Setup Options as Key-Value-Pairs over the whole body. A known option
|
||||
other than AUTHORIZATION_TOKEN appears once; each token reads (§8.9)."""
|
||||
opts = kvps(r.rest())
|
||||
for k in KNOWN_OPTIONS:
|
||||
if k != 0x03 and sum(1 for o in opts if o["type"] == k) > 1:
|
||||
raise Refused("duplicate")
|
||||
for o in opts:
|
||||
if o["type"] == 0x03:
|
||||
token(bytes.fromhex(o.get("bytes", "")))
|
||||
return {"options": opts}
|
||||
|
||||
|
||||
def body(name, r):
|
||||
if name == "SETUP":
|
||||
return setup_body(r)
|
||||
if name == "GOAWAY":
|
||||
uri = r.lp(8192)
|
||||
return {"new_session_uri": hx(uri), "timeout_ms": r.vi()}
|
||||
if name in ("SUBSCRIBE", "FETCH", "TRACK_STATUS"):
|
||||
rid = r.vi()
|
||||
ns = namespace(r)
|
||||
nm = track_name(r, ns)
|
||||
return {"request_id": rid, "namespace": ns_json(ns), "name": hx(nm), "parameters": parameters(r, name)}
|
||||
if name == "SUBSCRIBE_OK":
|
||||
alias = r.vi()
|
||||
ps = parameters(r, name)
|
||||
return {"track_alias": alias, "parameters": ps, "track_properties": track_props(r)}
|
||||
if name == "PUBLISH_STATE_NOTIFY":
|
||||
return {"parameters": parameters(r, name)}
|
||||
if name == "PUBLISH":
|
||||
rid = r.vi()
|
||||
ns = namespace(r)
|
||||
nm = track_name(r, ns)
|
||||
alias = r.vi()
|
||||
ps = parameters(r, name)
|
||||
return {"request_id": rid, "namespace": ns_json(ns), "name": hx(nm), "track_alias": alias, "parameters": ps, "track_properties": track_props(r)}
|
||||
if name == "PUBLISH_DONE":
|
||||
return {"status": r.vi(), "stream_count": r.vi(), "reason": hx(r.lp(1024))}
|
||||
if name == "FETCH_OK":
|
||||
eot = r.u8()
|
||||
if eot not in (0, 1):
|
||||
raise Refused("invalid-value")
|
||||
loc = [r.vi(), r.vi()]
|
||||
ps = parameters(r, name)
|
||||
return {"end_of_track": eot, "end_location": loc, "parameters": ps, "track_properties": track_props(r)}
|
||||
if name in ("PUBLISH_NAMESPACE", "SUBSCRIBE_NAMESPACE", "SUBSCRIBE_TRACKS"):
|
||||
rid = r.vi()
|
||||
ns = namespace(r)
|
||||
return {"request_id": rid, "prefix": ns_json(ns), "parameters": parameters(r, name)}
|
||||
if name in ("NAMESPACE", "NAMESPACE_DONE"):
|
||||
return {"suffix": ns_json(namespace(r))}
|
||||
if name == "PUBLISH_SKIPPED":
|
||||
ns = namespace(r)
|
||||
return {"suffix": ns_json(ns), "name": hx(track_name(r, ns))}
|
||||
if name == "REQUEST_UPDATE":
|
||||
rid = r.vi()
|
||||
return {"request_id": rid, "parameters": parameters(r, name)}
|
||||
if name == "REQUEST_OK":
|
||||
ps = parameters(r, name)
|
||||
return {"parameters": ps, "track_properties": track_props(r)}
|
||||
if name == "REQUEST_ERROR":
|
||||
code = r.vi()
|
||||
retry = r.vi()
|
||||
reason = r.lp(1024)
|
||||
redirect = None
|
||||
if code == 0x34: # REDIRECT carries a Redirect (§9.4.1)
|
||||
uri = r.lp()
|
||||
ns = namespace(r)
|
||||
redirect = {"connect_uri": hx(uri), "namespace": ns_json(ns), "name": hx(track_name(r, ns))}
|
||||
return {"code": code, "retry_interval": retry, "reason": hx(reason), "redirect": redirect}
|
||||
raise AssertionError(name)
|
||||
|
||||
|
||||
def control_message(r):
|
||||
"""§9: Type (vi64), Length (16), a body of exactly Length bytes."""
|
||||
t = r.vi()
|
||||
if t not in TYPES:
|
||||
raise Refused("unknown-message-type")
|
||||
ln = r.u16()
|
||||
b = Buf(r.take(ln))
|
||||
m = body(TYPES[t], b)
|
||||
b.done()
|
||||
m["message"] = TYPES[t]
|
||||
return m
|
||||
|
||||
|
||||
# §11 the data plane ------------------------------------------------------------
|
||||
|
||||
def subgroup_stream(t, r):
|
||||
"""§11.3.1: SUBGROUP_HEADER, then objects to the FIN."""
|
||||
mode = (t & 0x06) >> 1
|
||||
if mode == 3:
|
||||
raise Refused("invalid-flags")
|
||||
h = {"header": "subgroup", "track_alias": r.vi(), "group_id": r.vi(), "subgroup_id_mode": mode, "subgroup_id": None}
|
||||
if mode == 2:
|
||||
h["subgroup_id"] = r.vi()
|
||||
h["publisher_priority"] = None if t & 0x20 else r.u8()
|
||||
h["properties"] = bool(t & 0x01)
|
||||
h["end_of_group"] = bool(t & 0x08)
|
||||
h["first_object"] = bool(t & 0x40)
|
||||
events = [h]
|
||||
prev = None
|
||||
while not r.empty():
|
||||
delta = r.vi()
|
||||
oid = delta if prev is None else prev + delta + 1
|
||||
if oid > U64:
|
||||
raise Refused("overflow")
|
||||
pairs = kvps(r.lp()) if h["properties"] else []
|
||||
ln = r.vi()
|
||||
status = 0
|
||||
if ln == 0:
|
||||
status = r.vi()
|
||||
if status not in (0, 3, 4):
|
||||
raise Refused("unknown-object-status")
|
||||
if status != 0 and pairs:
|
||||
raise Refused("properties-on-non-normal")
|
||||
payload = r.take(ln)
|
||||
check_object_properties(pairs, h["group_id"], oid)
|
||||
prev = oid
|
||||
events.append({"object": {"object_id": oid, "properties": pairs, "status": status, "payload": hx(payload)}})
|
||||
return events
|
||||
|
||||
|
||||
RANGE_ENDS = {0x8C: "non-existent", 0x10C: "unknown", 0x20C: "timed-out"}
|
||||
|
||||
|
||||
def fetch_stream(r, order):
|
||||
"""§11.4.1: FETCH_HEADER, then objects and End of Range indicators, each
|
||||
referring to the one before it; Group IDs move in the request's Group Order."""
|
||||
events = [{"header": "fetch", "request_id": r.vi()}]
|
||||
prior = None # [group, object] of the prior entry
|
||||
prior_sub = "none" # the last actual object's Subgroup ID; None for a datagram
|
||||
prior_prio = None
|
||||
|
||||
def step(g, d):
|
||||
if order == 1:
|
||||
v = g + d + 1
|
||||
if v > U64:
|
||||
raise Refused("overflow")
|
||||
else:
|
||||
v = g - (d + 1)
|
||||
if v < 0:
|
||||
raise Refused("overflow")
|
||||
return v
|
||||
|
||||
while not r.empty():
|
||||
flags = r.vi()
|
||||
if flags >= 0x80:
|
||||
if flags not in RANGE_ENDS:
|
||||
raise Refused("invalid-flags")
|
||||
gd = r.vi()
|
||||
od = r.vi()
|
||||
g = gd if prior is None else step(prior[0], gd)
|
||||
prior = [g, od]
|
||||
events.append({"end_of_range": {"kind": RANGE_ENDS[flags], "location": [g, od]}})
|
||||
continue
|
||||
if flags & 0x08:
|
||||
d = r.vi()
|
||||
g = d if prior is None else step(prior[0], d)
|
||||
else:
|
||||
if prior is None:
|
||||
raise Refused("no-prior-object")
|
||||
g = prior[0]
|
||||
if flags & 0x40:
|
||||
sub = None
|
||||
else:
|
||||
mode = flags & 0x03
|
||||
if mode == 0:
|
||||
sub = 0
|
||||
elif mode in (1, 2):
|
||||
if prior_sub == "none" or prior_sub is None:
|
||||
raise Refused("no-prior-object")
|
||||
sub = prior_sub + (mode - 1)
|
||||
if sub > U64:
|
||||
raise Refused("overflow")
|
||||
else:
|
||||
sub = r.vi()
|
||||
if flags & 0x04:
|
||||
d = r.vi()
|
||||
if flags & 0x08:
|
||||
o = d
|
||||
else:
|
||||
if prior is None:
|
||||
raise Refused("no-prior-object")
|
||||
o = prior[1] + d
|
||||
else:
|
||||
if prior is None:
|
||||
raise Refused("no-prior-object")
|
||||
o = prior[1] + 1
|
||||
if o > U64:
|
||||
raise Refused("overflow")
|
||||
if flags & 0x10:
|
||||
prio = r.u8()
|
||||
else:
|
||||
if prior_prio is None:
|
||||
raise Refused("no-prior-object")
|
||||
prio = prior_prio
|
||||
pairs = kvps(r.lp()) if flags & 0x20 else None
|
||||
payload = r.take(r.vi())
|
||||
if pairs is not None:
|
||||
check_object_properties(pairs, g, o)
|
||||
prior, prior_sub, prior_prio = [g, o], sub, prio
|
||||
events.append({"fetch_object": {"group_id": g, "object_id": o, "subgroup_id": sub, "publisher_priority": prio, "properties": pairs, "payload": hx(payload)}})
|
||||
return events
|
||||
|
||||
|
||||
def control_stream(r):
|
||||
"""§6.3: a control stream opens with SETUP (its type is the stream's); after
|
||||
it only GOAWAY, once (§9.2, Table 5)."""
|
||||
ln = r.u16()
|
||||
b = Buf(r.take(ln))
|
||||
setup = setup_body(b)
|
||||
b.done()
|
||||
setup["message"] = "SETUP"
|
||||
events = [{"header": "control"}, {"control": setup}]
|
||||
goaway = False
|
||||
while not r.empty():
|
||||
m = control_message(r)
|
||||
if m["message"] != "GOAWAY" or goaway:
|
||||
raise Refused("unexpected-message")
|
||||
goaway = True
|
||||
events.append({"control": m})
|
||||
return events
|
||||
|
||||
|
||||
def uni_stream(b, order):
|
||||
"""§6.4.1, Table 2: the stream type picks the stream."""
|
||||
r = Buf(b)
|
||||
t = r.vi()
|
||||
if t == 0x05:
|
||||
return fetch_stream(r, order)
|
||||
if t == 0x2F00:
|
||||
return control_stream(r)
|
||||
if t == 0x132B3E28:
|
||||
n = len(r.rest())
|
||||
return [{"header": "padding"}] + ([{"padding": n}] if n else [])
|
||||
if t < 0x80 and t & 0x10:
|
||||
return subgroup_stream(t, r)
|
||||
raise Refused("unknown-stream-type")
|
||||
|
||||
|
||||
def datagram(b):
|
||||
"""§11.2.1 and §11.5.2."""
|
||||
r = Buf(b)
|
||||
t = r.vi()
|
||||
if t == 0x132B3E29:
|
||||
return {"padding": r.left()}
|
||||
if t & ~0x2F or (t & 0x22) == 0x22:
|
||||
raise Refused("invalid-flags")
|
||||
d = {"track_alias": r.vi(), "group_id": r.vi()}
|
||||
d["object_id"] = 0 if t & 0x04 else r.vi()
|
||||
d["publisher_priority"] = None if t & 0x08 else r.u8()
|
||||
pairs = None
|
||||
if t & 0x01:
|
||||
pb = r.lp()
|
||||
if not pb:
|
||||
raise Refused("empty-datagram-properties")
|
||||
pairs = kvps(pb)
|
||||
d["properties"] = pairs
|
||||
d["end_of_group"] = bool(t & 0x02)
|
||||
if t & 0x20:
|
||||
s = r.vi()
|
||||
if s not in (0, 3, 4):
|
||||
raise Refused("unknown-object-status")
|
||||
if s != 0 and pairs is not None:
|
||||
raise Refused("properties-on-non-normal")
|
||||
r.done()
|
||||
d["status"], d["payload"] = s, None
|
||||
else:
|
||||
d["status"], d["payload"] = None, hx(r.rest())
|
||||
if pairs is not None:
|
||||
check_object_properties(pairs, d["group_id"], d["object_id"])
|
||||
return {"datagram": d}
|
||||
|
||||
|
||||
def varint_only(b):
|
||||
r = Buf(b)
|
||||
v = r.vi()
|
||||
r.done()
|
||||
return {"value": v, "length": len(b)}
|
||||
|
||||
|
||||
def wai_receipt(b):
|
||||
"""The WAI_RECEIPT property's value, as the crate README lays it out:
|
||||
content_hash (32 bytes), joules_micro (a varint), acquisition (a varint length,
|
||||
UTF-8), acquisition_evidence (a varint length, bytes), group_receipt (32 bytes),
|
||||
nothing after."""
|
||||
try:
|
||||
r = Buf(b)
|
||||
ch = r.take(32)
|
||||
j = r.vi()
|
||||
acq = r.lp().decode("utf-8")
|
||||
ev = r.lp()
|
||||
gr = r.take(32)
|
||||
r.done()
|
||||
except (Refused, UnicodeDecodeError):
|
||||
raise Refused("invalid-property", "property_invalid")
|
||||
return {"content_hash": hx(ch), "joules_micro": j, "acquisition": acq, "acquisition_evidence": hx(ev), "group_receipt": hx(gr)}
|
||||
|
||||
|
||||
def decode(v):
|
||||
b = bytes.fromhex(v["hex"])
|
||||
kind = v["kind"]
|
||||
if kind == "varint":
|
||||
return varint_only(b)
|
||||
if kind == "control":
|
||||
r = Buf(b)
|
||||
m = control_message(r)
|
||||
r.done()
|
||||
return m
|
||||
if kind == "uni-stream":
|
||||
return uni_stream(b, v.get("context", {}).get("group_order", 1))
|
||||
if kind == "datagram":
|
||||
return datagram(b)
|
||||
if kind == "wai-receipt":
|
||||
return wai_receipt(b)
|
||||
raise AssertionError(f"unknown kind {kind}")
|
||||
|
||||
|
||||
def main(argv):
|
||||
path = argv[1] if len(argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "vectors.json")
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
doc = json.load(fh)
|
||||
vectors = doc.get("vectors", [])
|
||||
if not vectors:
|
||||
print(f"{path}: no vectors; refusing to pass on nothing")
|
||||
return 1
|
||||
failures = []
|
||||
good = bad = 0
|
||||
for v in vectors:
|
||||
exp = v["expect"]
|
||||
try:
|
||||
got = decode(v)
|
||||
if "error" in exp:
|
||||
failures.append(f"{v['name']}: read as {json.dumps(got)}; the corpus refuses it ({exp['error']})")
|
||||
elif got != exp:
|
||||
failures.append(f"{v['name']}: read as {json.dumps(got, sort_keys=True)}\n the corpus says {json.dumps(exp, sort_keys=True)}")
|
||||
else:
|
||||
good += 1
|
||||
except Refused as e:
|
||||
if "error" not in exp:
|
||||
failures.append(f"{v['name']}: refused ({e.kind}, {e.consequence}); the corpus reads it")
|
||||
elif (e.kind, e.consequence) != (exp["error"], exp["consequence"]):
|
||||
failures.append(f"{v['name']}: refused as {e.kind} ({e.consequence}); the corpus says {exp['error']} ({exp['consequence']})")
|
||||
else:
|
||||
bad += 1
|
||||
for f in failures:
|
||||
print(f)
|
||||
print(f"{good} read as stated, {bad} refused as stated, {len(failures)} disagree")
|
||||
return 1 if failures or not good or not bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue