Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ A second ANNOUNCE_START for an already-advertised route is a protocol violation;
The subscriber MUST close the session with a PROTOCOL_VIOLATION if it receives an ANNOUNCE_END, ANNOUNCE_UPDATE, or ANNOUNCE_RESTART referencing an Announce ID that was never assigned or already retired, an ANNOUNCE_START for a route that is already advertised, or any announcement before ANNOUNCE_OK.
When the stream is closed, the subscriber MUST assume that all routes are now unavailable.

A broadcast path has at most 32 segments and at most 16,384 bytes; a receiver MUST close the session with a PROTOCOL_VIOLATION on a longer one, including one resolved by [Compression](#announce-compression).
A broadcast path has at most 32 segments and at most 4,096 bytes; a receiver MUST close the session with a PROTOCOL_VIOLATION on a longer one, including one resolved by [Compression](#announce-compression).
A route covers a path when its prefix is a leading run of the path's segments; matching is per path segment, so a prefix never matches half a segment, and equality is byte-by-byte within each segment.
A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see.
There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements.
Expand Down Expand Up @@ -1826,7 +1826,7 @@ The `Message Length` describes the payload size on the wire.

## moq-lite-07

- Limited a broadcast path to 32 segments and 16,384 bytes.
- Limited a broadcast path to 32 segments and 4,096 bytes.

- A session without a negotiated subprotocol negotiates the version in a draft-14 CLIENT_SETUP and SERVER_SETUP exchange. Implementations apply this to moq-lite-03 through moq-lite-06 too.

Expand Down
5 changes: 5 additions & 0 deletions js/net/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first.

## Unreleased

### Paths fit moq-transport's 4096-byte namespace ([#6927](https://github.com/moq-dev/moq/pull/6927))

`Path.MAX_BYTES` is 4096, moq-transport's namespace limit, so every path fits every wire and an origin refuses a longer one. Keep broadcast paths within 4096 bytes.
A moq-transport namespace whose escaped path would exceed it is refused when decoded, and a SUBSCRIBE, TRACK\_STATUS, or PUBLISH whose namespace and track name exceed 4096 bytes together fails that request instead of being sent.

### `Path.from` refuses empty segments ([#6648](https://github.com/moq-dev/moq/pull/6648))

`Path.from` throws on a component with a leading, trailing, or doubled `/` instead of collapsing it, so a path has one spelling, and `Path.decode` refuses one off the wire. A moq-transport namespace field ending in `/`, which escapes to an empty segment, is refused too.
Expand Down
2 changes: 1 addition & 1 deletion js/net/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ function isWebTransportSupported(): boolean;

function join(path: Valid, other: Valid): Valid;

const MAX_BYTES = 16384;
const MAX_BYTES = 4096;

const MAX_GROUP_CACHE_BYTES: number;

Expand Down
45 changes: 44 additions & 1 deletion js/net/src/ietf/namespace.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test";
import { fromTuple, reserved } from "./namespace.ts";
import { ProtocolViolation } from "../error.ts";
import * as Path from "../path.ts";
import { Reader, Writer } from "../stream.ts";
import { decode, decodeTrack, encode, encodeTrack, fromTuple, reserved } from "./namespace.ts";
import { Version } from "./version.ts";

test("reserved namespaces preserve tuple fields and draft boundaries", () => {
Expand All @@ -17,3 +20,43 @@ test("reserved namespaces preserve tuple fields and draft boundaries", () => {
expect(reserved(path, Version.DRAFT_18)).toBe(false);
}
});

test("separators and escapes count toward a path but not its tuple fields", async () => {
const segments = Array(Path.MAX_PARTS - 1)
.fill("a")
.join("/");
const deepest = Path.from(`${segments}/${"é".repeat(Path.MAX_BYTES / 2 - 31)}`);
expect(await roundTrip((w) => encode(w, deepest), decode)).toBe(deepest);

expect(fromTuple([`${"/".repeat(Path.MAX_BYTES / 2 - 1)}a`]).length).toBe(Path.MAX_BYTES - 1);
expect(() => fromTuple([`${"/".repeat(Path.MAX_BYTES / 2)}a`])).toThrow(ProtocolViolation);
});

test("a full track name counts field bytes and is refused before writing", async () => {
// 4095 path bytes holding 2048 field bytes.
const namespace = Path.from(`${"\\/".repeat(Path.MAX_BYTES / 2 - 1)}a`);
const name = "x".repeat(2048);
expect(await roundTrip((w) => encodeTrack(w, namespace, name), decodeTrack)).toEqual({ namespace, name });
await expect(roundTrip((w) => encodeTrack(w, namespace, `${name}x`), decodeTrack)).rejects.toThrow(
"full track name exceeds",
);
});

/** Write with `write`, then read the bytes back with `read`. */
async function roundTrip<T>(write: (w: Writer) => Promise<void>, read: (r: Reader) => Promise<T>): Promise<T> {
const bytes: number[] = [];
const writer = new Writer(
new WritableStream<Uint8Array>({
write: (chunk) => {
bytes.push(...chunk);
},
}),
Version.DRAFT_22,
);
try {
await write(writer);
} finally {
await writer.close();
}
return read(new Reader(undefined, Uint8Array.from(bytes), Version.DRAFT_22));
}
35 changes: 29 additions & 6 deletions js/net/src/ietf/namespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ function toTuple(namespace: Path.Valid): string[] {
/**
* Convert IETF namespace tuple fields into the escaped moq-net path representation.
*
* Throws when a field ends in `/`, which escapes to a path with an empty segment.
* Throws when a field ends in `/`, which escapes to a path with an empty segment, or when the
* separators and escapes push the path past {@link Path.MAX_BYTES}.
*/
export function fromTuple(parts: string[]): Path.Valid {
const escaped = parts.map((part) => part.replaceAll("\\", "\\\\").replaceAll("/", "\\/")).join("/");
if (tooLong(escaped)) throw new ProtocolViolation(`namespace exceeds ${Path.MAX_BYTES} bytes as a path`);
try {
return Path.from(escaped);
} catch (cause) {
Expand All @@ -38,21 +40,42 @@ export function fromTuple(parts: string[]): Path.Valid {

/** Encode a moq-net namespace as an IETF namespace tuple. */
export async function encode(w: Writer, namespace: Path.Valid): Promise<void> {
const parts = toTuple(namespace);
await writeFields(w, fields(namespace));
}

// The IETF draft limits namespaces to 32 parts.
if (parts.length > Path.MAX_PARTS) {
throw new Error(`namespace exceeds ${Path.MAX_PARTS} parts`);
}
/** Encode a full track name: a namespace tuple then a track name, bounded together. */
export async function encodeTrack(w: Writer, namespace: Path.Valid, name: string): Promise<void> {
const parts = fields(namespace);
let size = utf8.encode(name).length;
for (const part of parts) size += utf8.encode(part).length;
if (size > MAX_BYTES) throw new Error(`full track name exceeds ${MAX_BYTES} bytes`);
await writeFields(w, parts);
await w.string(name);
}

function fields(namespace: Path.Valid): string[] {
// The fields drop the path's separators and escapes, so a path within Path.MAX_BYTES fits.
if (tooLong(namespace)) throw new Error(`namespace exceeds ${Path.MAX_BYTES} bytes`);
const parts = toTuple(namespace);
if (parts.length > Path.MAX_PARTS) throw new Error(`namespace exceeds ${Path.MAX_PARTS} parts`);
return parts;
}

async function writeFields(w: Writer, parts: string[]): Promise<void> {
await w.u53(parts.length);
for (const part of parts) {
await w.string(part);
}
}

// A UTF-16 unit is at most 3 UTF-8 bytes, so only a long path needs encoding to measure.
function tooLong(path: string): boolean {
return path.length > Path.MAX_BYTES / 3 && utf8.encode(path).length > Path.MAX_BYTES;
}

// Every draft caps a namespace, and a namespace plus track name, at 4096 bytes.
const MAX_BYTES = 4096;
const utf8 = new TextEncoder();

/** Decode an IETF namespace tuple into an escaped moq-net namespace. */
export async function decode(r: Reader): Promise<Path.Valid> {
Expand Down
3 changes: 1 addition & 2 deletions js/net/src/ietf/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ export class Publish {

async #encode(w: Writer, version: IetfVersion): Promise<void> {
await w.u62(this.requestId);
await Namespace.encode(w, this.trackNamespace);
await w.string(this.trackName);
await Namespace.encodeTrack(w, this.trackNamespace, this.trackName);
await w.u62(this.trackAlias);

if (this.contentExists !== !!this.largest) {
Expand Down
3 changes: 1 addition & 2 deletions js/net/src/ietf/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,7 @@ export class Subscribe {

async #encode(w: Writer, version: IetfVersion): Promise<void> {
await w.u62(this.requestId);
await Namespace.encode(w, this.trackNamespace);
await w.string(this.trackName);
await Namespace.encodeTrack(w, this.trackNamespace, this.trackName);

const params = new Parameters();
params.subscriberPriority = this.subscriberPriority;
Expand Down
3 changes: 1 addition & 2 deletions js/net/src/ietf/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ export class TrackStatusRequest {

async #encode(w: Writer, version: IetfVersion): Promise<void> {
await w.u62(this.requestId);
await Namespace.encode(w, this.trackNamespace);
await w.string(this.trackName);
await Namespace.encodeTrack(w, this.trackNamespace, this.trackName);

// Only the opt-out is worth bytes, and a peer before draft-22 would read
// INCLUDE_PROPERTIES as unknown, which is a protocol violation.
Expand Down
9 changes: 7 additions & 2 deletions js/net/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ export type Relative = string & { __brand: "Relative" };
*/
export const MAX_PARTS = 32;

/** Maximum UTF-8 length of a path in bytes, leaving room in a 64 KiB moq-lite message for its other fields. */
export const MAX_BYTES = 16384;
/**
* Maximum UTF-8 length of a path in bytes.
*
* Matches the IETF moq-transport limit of 4096 bytes for a namespace. Its tuple fields
* drop the path's separators and escapes, so every path fits both wires.
*/
export const MAX_BYTES = 4096;

const utf8 = new TextEncoder();

Expand Down
1 change: 0 additions & 1 deletion quest/m1/cut-audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ leaving JS-only redesigns of `js/net` to [rs2ts](/quest/m2/rs2ts/README.md).

- [Counted field-less `trun` entries](/quest/m1/cut-audit/fmp4-sample-resolver.md) - a moof of field-less runs cannot materialize millions of sample entries
- [A compressed announce path never outgrows its literal form](/quest/m1/cut-audit/announce-path-bytes.md) - lite-07 announce compression cannot build multi-MiB paths
- [A route too large to re-encode is skipped](/quest/m1/cut-audit/announce-reencode.md) - one oversized route never resets a downstream announce stream
- [A reserved group survives a route returning](/quest/m1/cut-audit/reserved-route-return.md) - a route flip A to B to A no longer panics a peeked reader
- [JS tracks end clean only on a deliberate finish](/quest/m1/cut-audit/js-close-cause.md) - local close, violations, and unanswered requests end with errors
- [Withdrawing a moq-transport FETCH or TRACK_STATUS ends it](/quest/m1/cut-audit/ietf-withdraw-resolve.md) - withdrawal during resolution releases the request
Expand Down
4 changes: 0 additions & 4 deletions quest/m1/cut-audit/announce-path-bytes.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,3 @@ parts-only check.
unpublished, so this changes no published version).

Public API: none. Wire: one receiver rule in the lite-07 draft.

## Related

- [A route too large to re-encode is skipped](/quest/m1/cut-audit/announce-reencode.md) - the same size bound on the send side
37 changes: 0 additions & 37 deletions quest/m1/cut-audit/announce-reencode.md

This file was deleted.

7 changes: 6 additions & 1 deletion rs/moq-net/MIGRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first.

## Unreleased

### Paths fit moq-transport's 4096-byte namespace ([#6927](https://github.com/moq-dev/moq/pull/6927))

`Path::MAX_BYTES` is 4096, moq-transport's namespace limit, so every path fits every wire and building a longer one fails with `path::Invalid::TooLong`. Keep broadcast paths within 4096 bytes.
A moq-transport namespace whose escaped path would exceed it is refused when decoded, and a SUBSCRIBE, TRACK\_STATUS, FETCH, or PUBLISH whose namespace and track name exceed 4096 bytes together fails that request with `Error::Encode` instead of being sent.

### Building a `Path` refuses empty segments ([#6648](https://github.com/moq-dev/moq/pull/6648))

`Path::new` and `TryFrom` fail with `path::Invalid::EmptySegment` on a leading, trailing, or doubled `/` instead of collapsing it, so a path has one spelling; the empty path is still the root. Origin entry points report it as `Error::EmptySegment`, and `Path::from_static` panics on it.
Expand All @@ -20,7 +25,7 @@ The moql SETUP (no ALPN, or `moql`) now negotiates only moq-lite versions.

### `Path` and `Relative` have no lifetime

There is one owned `Path` type: a short path is stored inline and a long one shares a reference-counted buffer, so cloning stays cheap. Every path is at most `Path::MAX_BYTES` (16 KiB).
There is one owned `Path` type: a short path is stored inline and a long one shares a reference-counted buffer, so cloning stays cheap. Every path is at most `Path::MAX_BYTES`.

- `PathOwned`, `Path<'a>`, and `Path<'static>` are all `Path`; `RelativeOwned` and `Relative<'a>` are `Relative`.
- `Path::{to_owned, into_owned, borrow}` and the `Relative` equivalents are gone: drop the call, or `clone()` a `&Path` you need to keep.
Expand Down
10 changes: 5 additions & 5 deletions rs/moq-net/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,11 @@ fn ietf_paths(data: &[u8], version: ietf::Version) {
if let Ok(path) = ietf::namespace::decode_namespace(&mut Decoder::new(data, form)) {
assert_strict(&path);
let mut encoded = Vec::new();
if ietf::namespace::encode_namespace(&mut Encoder::new(&mut encoded, form), &path).is_ok() {
let echo = ietf::namespace::decode_namespace(&mut Decoder::new(&encoded, form))
.expect("could not decode our own namespace");
assert_eq!(echo, path, "namespace did not survive a round trip");
}
ietf::namespace::encode_namespace(&mut Encoder::new(&mut encoded, form), &path)
.expect("could not encode a decoded namespace");
let echo = ietf::namespace::decode_namespace(&mut Decoder::new(&encoded, form))
.expect("could not decode our own namespace");
assert_eq!(echo, path, "namespace did not survive a round trip");
}
if let Ok((path, _)) = ietf::namespace::decode_track(&mut Decoder::new(data, form)) {
assert_strict(&path);
Expand Down
8 changes: 3 additions & 5 deletions rs/moq-net/src/ietf/fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder},
ietf::{
Filter, GroupOrder, Location, Opaque, RequestId,
namespace::{decode_track, encode_namespace},
namespace::{decode_track, encode_track},
},
};

Expand Down Expand Up @@ -54,8 +54,7 @@ impl Encode<Version> for FetchType<'_> {
end,
} => {
w.u8(1);
encode_namespace(w, namespace)?;
w.string(track)?;
encode_track(w, namespace, track)?;
start.encode(w, version)?;
end.encode(w, version)?;
}
Expand Down Expand Up @@ -171,8 +170,7 @@ impl Message for Fetch<'_> {
else {
return Err(EncodeError::Version);
};
encode_namespace(w, namespace)?;
w.string(track)?;
encode_track(w, namespace, track)?;
// INCLUDE_PROPERTIES defaults to 1, so only the opt-out is worth bytes.
let include_properties = (!self.properties_wanted).then_some(false);
encode_params!(w, version,
Expand Down
Loading
Loading