diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index d46c8851ae..0a1eac8e09 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -43,7 +43,9 @@ certificate, or nothing), so a publisher learns before anyone subscribes whether its broadcasts can reach the peer. More tokens can be presented later without reconnecting; the session's scope is the union of every open token's grant, and withdrawing, revoking, or narrowing one withdraws only what it alone -covered. A subscription or fetch that loses access resets with the +covered. A grant is a union of [path patterns](#path-patterns), so `room/*/cam` +or the exact broadcast `room/alice` arrives as issued rather than widened to a +prefix. A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream code, so the peer can tell it apart from the session closing. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b897297151..658066a594 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -54,7 +54,7 @@ for (;;) { - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. -- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. +- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band, or on moq-transport when the grant is not a union of subtrees, which its namespace prefixes cannot carry. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1bab190e34..f8b8d09451 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -93,9 +93,10 @@ origin handles allow and refuses any other token as unsupported. To verify tokens yourself, take `handshake.auth().requests()` on the `server::Handshake` before `ok()` (or `session.auth().requests()` before first polling the driver) and answer every `auth::Request` with `accept(grant)`, which returns an -`auth::Issued` you can `update` or `revoke`, or `reject`. Both wires carry -prefix grants for now, so a grant that is not a union of subtrees is refused -and the presenter sees `Unsupported`; after a grant, such an update revokes it. +`auth::Issued` you can `update` or `revoke`, or `reject`. moq-lite carries any +pattern grant as issued. moq-transport carries namespace prefixes, so there a +grant that is not a union of subtrees is refused and the presenter sees +`Unsupported`; after a grant, such an update revokes it. A client whose origin publishes a broadcast outside its grant closes the session with `Unauthorized`, naming the path in the close reason. A grant that diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 0ce3f98769..2b4662f099 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1321,24 +1321,43 @@ AUTH_OK Message { Type (i) = 0x0 Message Length (i) Publish Count (i) - Publish Prefix (s) ... + Publish Pattern (s) ... Subscribe Count (i) - Subscribe Prefix (s) ... + Subscribe Pattern (s) ... Expires (i) } ~~~ -**Publish Prefix**: -A path prefix the opener may announce and serve, using the encoding and matching rules of the ANNOUNCE_REQUEST [prefix](#announce-request). -An empty prefix grants every path, and a count of zero grants none. +**Publish Pattern**: +A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve. +A count of zero grants none. -**Subscribe Prefix**: -A path prefix the opener may subscribe to, with the same rules. +**Subscribe Pattern**: +A [pattern](#path-pattern) matching broadcast paths the opener may subscribe to. **Expires**: The number of milliseconds until the grant lapses, or 0 for never. The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time. +### Path Pattern {#path-pattern} +A pattern is a set of broadcast paths, written as `/`-separated segments. +Each segment is one of: + +- a literal, matching that segment exactly; +- `*`, matching any one segment; +- a literal with one `*` inside it, such as `cam-*.hang`, matching any segment that starts with the bytes before the `*` and ends with the bytes after it, without overlapping them; +- `**`, matching zero or more segments. + +A pattern matches a path when its segments match the path's segments in order, covering the whole path. +The empty pattern matches only the empty path, `room/**` matches `room` and every path beneath it, and `**` matches every path. + +A pattern has at most 32 segments and at most one `**`. +It has no leading, trailing, or repeated `/`, no segment with more than one `*`, and no `**` combined with other bytes in a segment. +It is canonical: a `**` is never immediately preceded by a `*` segment, since `*/**` and `**/*` match the same paths and only `**/*` is valid. +A pattern that breaks any of these rules is a PROTOCOL_VIOLATION. + +A grant covers a request when one of its patterns matches the path. + ## AUTH_ERROR {#auth-error} AUTH_ERROR refuses a token, or revokes it after an AUTH_OK. @@ -1425,7 +1444,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. -- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the paths it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. +- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - Require error-code translation when bridging protocols and draft versions. - Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. - Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 208f82186f..dd1a947897 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -158,7 +158,29 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro server.close(); }); - test("a grant the wire cannot carry is unsupported and leaves the rest alone", async () => { + test("a refused setup token grants nothing rather than everything", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + request.reject(SessionCode.Unauthorized, "bad credential"); + } + })(); + + const empty = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(empty?.publish.size).toBe(0); + expect(empty?.subscribe.size).toBe(0); + client.close(); + server.close(); + }); +}); + +// moq-transport carries namespace prefixes, so a grant that is not a union of subtrees +// is refused there rather than widened. +describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("a grant namespace prefixes cannot carry is unsupported and leaves the rest alone", async () => { const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol }); const requests = server.auth.requests(); const issued: Issued[] = []; @@ -188,7 +210,7 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro } expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true); - // An update the wire cannot carry revokes that token's grant, and only that one. + // An update namespace prefixes cannot carry revokes that token's grant, and only that one. const t1 = await client.auth.add("t1"); await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); issued[issued.length - 1]?.update({ @@ -207,24 +229,40 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro client.close(); server.close(); }); +}); - test("a refused setup token grants nothing rather than everything", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - request.reject(SessionCode.Unauthorized, "bad credential"); - } - })(); +// moq-lite carries patterns, so every grant arrives exactly as issued. +test("lite-06 carries literal and wildcard grants exactly", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_06 }); + const requests = server.auth.requests(); + const unions: Record = { + "": [["a/**"], []], + exact: [["room/alice"], []], + wildcard: [["room/*/cam"], ["**/demo.hang"]], + mixed: [["room/**", "lobby", "cam-*.hang"], []], + root: [[""], ["**"]], + }; + const parse = (texts: string[]) => new Path.Patterns(texts.map((text) => Path.Pattern.parse(text))); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const [publish, subscribe] = unions[new TextDecoder().decode(request.token)] ?? [[], []]; + issued.push(request.accept({ publish: parse(publish), subscribe: parse(subscribe) })); + } + })(); - const empty = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(empty?.publish.size).toBe(0); - expect(empty?.subscribe.size).toBe(0); - client.close(); - server.close(); - }); + await waitFor(client.auth.grant, (g) => g !== undefined); + for (const token of ["exact", "wildcard", "mixed", "root"]) { + const [publish, subscribe] = unions[token]; + const added = await client.auth.add(token); + const got = added.grant.peek(); + expect(got?.publish.equals(parse(publish))).toBe(true); + expect(got?.subscribe.equals(parse(subscribe))).toBe(true); + } + client.close(); + server.close(); }); test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index 77006495a3..43b34b632c 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -1,13 +1,12 @@ import { expect, test } from "bun:test"; -import { Unsupported } from "../auth.ts"; import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; import * as Lite from "./index.ts"; -function patterns(...prefixes: string[]): Path.Patterns { - return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +function patterns(...texts: string[]): Path.Patterns { + return new Path.Patterns(texts.map((text) => Path.Pattern.parse(text))); } /** Round-trip bytes through a writer and back out of a reader. */ @@ -40,7 +39,7 @@ test("AUTH round-trips its token", async () => { }); test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { - const ok = new AuthOk(patterns(""), patterns(), 60_000); + const ok = new AuthOk(patterns("**"), patterns(), 60_000); const err = new AuthError(SessionCode.Unauthorized, "expired"); const r = await roundTrip(async (w) => { await encodeAuthReply(w, ok, Lite.Version.DRAFT_06); @@ -49,7 +48,7 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); expect(first).toBeInstanceOf(AuthOk); if (!(first instanceof AuthOk)) throw new Error("unreachable"); - // The empty prefix grants everything; the empty list grants nothing. + // `**` grants everything; the empty list grants nothing. expect(first.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true); expect(first.subscribe.size).toBe(0); expect(first.expires).toBe(60_000); @@ -59,9 +58,44 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).toBeUndefined(); }); -test("a grant the prefix wire cannot express is refused, never widened", async () => { - const narrow = new AuthOk(new Path.Patterns([Path.Pattern.literal("room/alice")]), patterns()); - await expect(roundTrip((w) => narrow.encode(w, Lite.Version.DRAFT_06))).rejects.toBeInstanceOf(Unsupported); +test("literal and wildcard grants travel exactly, never widened", async () => { + const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**")); + const got = await AuthOk.decode(await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)), Lite.Version.DRAFT_06); + expect(got.publish.equals(ok.publish)).toBe(true); + expect(got.subscribe.equals(ok.subscribe)).toBe(true); +}); + +// The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`. +test("AUTH_OK matches the Rust encoding", async () => { + const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000); + const r = await roundTrip((w) => encodeAuthReply(w, ok, Lite.Version.DRAFT_06)); + const text = (s: string) => [s.length, ...new TextEncoder().encode(s)]; + expect([...(await r.readAll())]).toEqual([ + 0x00, // AUTH_OK + 0x1a, // length + 0x02, // publish count, in canonical order + ...text("**/b.hang"), + ...text("room/*/cam"), + 0x01, // subscribe count + 0x00, // the empty pattern: the root alone + 0x43, + 0xe8, // expires: 1000ms + ]); +}); + +test("only valid, canonical patterns decode", async () => { + for (const text of ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"]) { + const r = await roundTrip(async (w) => { + const body = new TextEncoder().encode(text); + await w.u53(0); // AUTH_OK + await w.u53(1 + 1 + body.byteLength + 1 + 1); + await w.u53(1); + await w.string(text); + await w.u53(0); + await w.u53(0); + }); + await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).rejects.toThrow(); + } }); test("lite-05 carries no AUTH", async () => { diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 8a6bf2bd08..f1a29d42d7 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -43,23 +43,21 @@ export class AuthMessage { } } -// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of subtrees is -// representable. Anything else is refused rather than widened to its head. -async function encodePrefixes(w: Writer, patterns: Path.Patterns) { - const prefixes = patterns.toArray().map((pattern) => { - const prefix = pattern.asPrefix(); - if (prefix === undefined) throw new Unsupported(`grant not representable as prefixes: ${pattern}`); - return prefix; - }); - await w.u53(prefixes.length); - for (const prefix of prefixes) await w.string(prefix); +// Each pattern travels as its canonical text. +async function encodePatterns(w: Writer, patterns: Path.Patterns) { + await w.u53(patterns.size); + for (const pattern of patterns) await w.string(pattern.text); } -async function decodePrefixes(r: Reader): Promise { +async function decodePatterns(r: Reader): Promise { const count = await r.u53(); const patterns = new Path.Patterns(); for (let i = 0; i < count; i++) { - patterns.insert(Path.Pattern.subtree(await r.string())); + const text = await r.string(); + const pattern = Path.Pattern.parse(text); + // Only the canonical spelling is valid, so each pattern has one encoding. + if (pattern.text !== text) throw new Error(`non-canonical pattern: ${text}`); + patterns.insert(pattern); } return patterns; } @@ -78,8 +76,8 @@ export class AuthOk { } async #encode(w: Writer) { - await encodePrefixes(w, this.publish); - await encodePrefixes(w, this.subscribe); + await encodePatterns(w, this.publish); + await encodePatterns(w, this.subscribe); // 0 means never, so a lapsed grant rounds up to the smallest real expiry. const expires = this.expires === undefined ? 0 : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER); @@ -87,8 +85,8 @@ export class AuthOk { } static async #decode(r: Reader): Promise { - const publish = await decodePrefixes(r); - const subscribe = await decodePrefixes(r); + const publish = await decodePatterns(r); + const subscribe = await decodePatterns(r); const expires = await r.u53(); return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires); } diff --git a/js/pattern/src/index.ts b/js/pattern/src/index.ts index c95676ea42..84482d20e4 100644 --- a/js/pattern/src/index.ts +++ b/js/pattern/src/index.ts @@ -402,7 +402,9 @@ export class Pattern { */ static parse(text: string): Pattern { if (text === "") return new Pattern([]); - return new Pattern(text.split("/").map(parseSegment)); + // One past the limit is enough for the constructor to refuse, without splitting + // a peer's megabytes of `a/a/...` into segments first. + return new Pattern(text.split("/", MAX_PATTERN_SEGMENTS + 1).map(parseSegment)); } /** A pattern from its segments, validating the grammar. Throws {@link InvalidPattern}. */ diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 6becd2760c..242ca019e1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -45,9 +45,8 @@ Decisions settled while planning, recorded so review does not relitigate them: - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped - permission is `**`; an empty union grants nothing. [Path patterns](/quest/m1/auth/patterns.md) - ships with AUTH, so AUTH_OK carries those patterns, wildcards and literals - alike, from the first release. There is no prefix-only AUTH_OK and no + permission is `**`; an empty union grants nothing. AUTH_OK carries those + patterns, wildcards and literals alike, from the first release. There is no prefix-only AUTH_OK and no covering-prefix workaround. Announce stays a prefix: ANNOUNCE_REQUEST and SUBSCRIBE_NAMESPACE do not gain patterns in that change. The public grant type stays pattern-valued. @@ -90,8 +89,6 @@ existing lite-06 ALPN. ## Quests -- [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path - predicate, and AUTH_OK carries pattern grants from AUTH's first release - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/patterns.md b/quest/m1/auth/patterns.md deleted file mode 100644 index bbc5779357..0000000000 --- a/quest/m1/auth/patterns.md +++ /dev/null @@ -1,103 +0,0 @@ -# [M] Path patterns - -## Goal - -Every predicate over a MoQ broadcast path uses one matcher. Tokens, -origin scopes, announce interests, public access rules, and wildcard -advertisements can express `pid/*/chat` and `**/transcode.pro` without -maintaining competing glob dialects. - -Literal paths remain coordinates, not sets. Roots, joins, exact broadcast -names, URL paths, filesystem paths, and object-store keys keep their own -types. The relay's `/announced/*prefix` debug endpoint remains a prefix-only -exception. - -## Plan - -### Dialect - -A v1 pattern is canonical `/`-separated segments: - -- a literal; -- `*`, matching one complete segment; -- `lit*lit`, with one `*` matching bytes inside one segment (`*.hang`, `foo*`, - `foo.*.hang`); -- `**`, matching zero or more complete segments, at most once per pattern. - -Patterns are exact by default. `foo` matches only `foo`, `foo/**` matches its -subtree including `foo`, `**` matches every path, and the empty pattern matches -only the current root. Reject leading, trailing, or repeated `/`, more than one -`*` in a segment, `**` mixed with literal bytes, and more than one `**`. A -second star in a segment stays reserved: matching it is still linear, but -containment stops being two string compares. Literal `*` needs no escape: new -path construction and publication reject it, while decoders tolerate it on -legacy protocol versions during rollout. - -Construction moves `**` before adjacent `*` segments: `*/**` becomes -`**/*`. Equivalent wildcard placements therefore share one text and identity. - -A pattern list is an unordered union reduced by containment. The shared -algebra supplies matching, overlap, containment, a literal head, and exact -set-valued rebasing. The set-valued result is load-bearing: rebasing `**/a` at -`a` must preserve both the root match and deeper paths ending in `a`. Union -containment is per member: a candidate covered only jointly by several members -(`a/**` against `a`, `a/*`, `a/*/**`) is refused, so the check stays linear and -a grant that means a subtree writes `a/**`. Pattern precedence uses one total -structural specificity everywhere rules overlap, ordered by literal segments, -then no `**`, then `lit*lit` segments, then `*` segments, then literal bytes -pinned inside `lit*lit` segments, then literal head length. That order agrees -with containment (a strict superset always ranks lower); equal patterns form -the same tier. - -### Ownership and compatibility - -`moq-pattern` and `@moq/pattern` own the grammar and algebra; `moq-net`, -`moq-auth`, `@moq/net`, and `@moq/auth` re-export them. Literal `Path` types -stay in `moq-net` / `@moq/net`. Golden cross-language vectors -(`rs/moq-pattern/tests/pattern.json`), exhaustive small cases, randomized round -trips, and the moq-net fuzz harness's `pattern` target prevent semantic drift at -the authorization boundary. Matching is linear and inherits `Path::MAX_PARTS` -(32), which also bounds residual expansion. - -Grants and claims carry no version. `moq-auth` grants are patterns: `foo` -means exactly `foo` and a subtree is `foo/**`. The published `put`/`get` -prefix encoding stays readable as subtrees, and subtree-only grants are still -written in it so older verifiers keep working. A wire message that carried -prefixes keeps them on the protocol versions that defined them; only new -versions carry patterns. - -The syntax follows Ant-style path patterns without `?`, classes, or braces. -NATS subjects motivate segment wildcards and reserved wildcard bytes; Vault -ACLs motivate structural specificity. Common Access -Token and `draft-ietf-moq-c4m-01` provide exact, prefix, and suffix matches per -namespace field, including exact depth with a trailing `nil`. Document the -exact common subset and keep the richer MoQ forms explicit rather than claiming -CAT cannot represent `pid/*/chat`. - -### Grants on the wire - -AUTH grants on moq-lite-06 carry the shared pattern semantics, without -changing older protocol versions. Interest stays a prefix: [#3770](https://github.com/moq-dev/moq/pull/3770) -keeps patterns off the announce wire, so ANNOUNCE_REQUEST and -SUBSCRIBE_NAMESPACE carry the prefix the caller asked for and a wildcard is -an optional filter on the consume side. - -AUTH has not shipped in a release yet: it lives on this line. Land patterns -here, before the line merges, so AUTH_OK carries pattern grants (wildcards and -literals alike) from its first release and never ships a prefix-only encoding. -Today's encoder refuses any grant that is not a subtree -(`rs/moq-net/src/lite/auth.rs`), so a literal grant such as `b1.hang` reaches -the client as no grant at all while the relay's origin enforces it correctly; -this quest removes that gap rather than widening the grant to a covering -prefix. Replace the AUTH grant prefixes with patterns in Rust and JavaScript in -the same change, and update the lite draft and fixtures together. Authorize by -exact containment in the subscriber's v1 grant. Cluster peers adopt nothing as a side -effect of this wire work. - -Test Rust and JavaScript interop, leading wildcards, `**` zero-segment -matches, containment refusal, and old-version behavior. - -## Related - -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the - matcher while retaining its own cost, pool, refusal, and resolution work diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 2a7fd5fba2..4d03299fb2 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -83,4 +83,3 @@ Additive. ## Required - [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships -- [Pattern interest](/quest/m1/auth/patterns.md) - AUTH can represent the complete grants relay revalidation returns diff --git a/quest/m1/bench-coverage.md b/quest/m1/bench-coverage.md index 9adb4c4a30..5b2abc9aff 100644 --- a/quest/m1/bench-coverage.md +++ b/quest/m1/bench-coverage.md @@ -16,9 +16,7 @@ and update, `moq-auth` token verification, and `moq-pattern` path matching. - `moq-auth`: JWT verification per connection, swept over algorithm and claim size. The in-band token path gets its bench with [In-band token](/quest/m1/auth/token-in-band.md), not here. -- `moq-pattern`: matching swept over pattern count and path depth. Coordinate - with [Path patterns](/quest/m1/auth/patterns.md) so the matcher gets one - bench, not two. +- `moq-pattern`: matching swept over pattern count and path depth. Anything that fans out gets a sweep over both axes. Name each target after what it measures, so the CI comment reads without opening the file. diff --git a/quest/m1/epoch.md b/quest/m1/epoch.md index 4e78ce8492..fffcfc2850 100644 --- a/quest/m1/epoch.md +++ b/quest/m1/epoch.md @@ -21,7 +21,7 @@ line builds on, and it replaces the e2ee-local `moq_e2ee::Epoch`. like `@alice` is valid today and stays valid: strict parsing already keeps it from reading as an epoch. Rejecting it instead would break the path contract and land on `dev`. Check how the split interacts with - [path patterns](/quest/m1/auth/patterns.md) and + path patterns (`rs/moq-pattern`) and hidden broadcasts (a leading `.`, see `doc/concept/moq-lite.md`). - `moq-e2ee` uses the shared type. Update [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md) so the path is diff --git a/quest/m1/wildcard/README.md b/quest/m1/wildcard/README.md index 130cf72dd9..fd72a53ac6 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m1/wildcard/README.md @@ -46,9 +46,8 @@ Route cost already names this case: "The original publisher seeds it with its production cost: zero for a live publish, something large for a standby that would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_auth::Claims.publish` and -`origin::Producer` gain versioned patterns through -[Path patterns](/quest/m1/auth/patterns.md), so advertisements reuse the -same exact containment check. `Cost { warm, cold }` +`origin::Producer` scope by `moq-pattern` patterns, so advertisements reuse +the same exact containment check. `Cost { warm, cold }` (`rs/moq-net/src/model/origin.rs:426`) is the route cost since [#2925](https://github.com/moq-dev/moq/pull/2925). @@ -84,7 +83,7 @@ field. ### Decisions - **One prefix on the wire, one pattern in the token and the filter.** An - advertisement is a path prefix; the [path-patterns](/quest/m1/auth/patterns.md) + advertisement is a path prefix; the `moq-pattern` dialect is what tokens and the consume-side filter use, matched by the shared matcher, so nothing resembles a second grammar and nothing on the wire spells a wildcard. @@ -192,7 +191,7 @@ field. - **Patterns are independent of clustering.** The `moq-pattern` crate owns the matching semantics tokens and filters share, with no draft of its own; no announce message carries a pattern on either protocol (AUTH grants on - lite-06 do, per [Path patterns](/quest/m1/auth/patterns.md)). moq-cluster adds hop + lite-06 do). moq-cluster adds hop lists, costs, pool selection, and request resolution to prefix advertisements. @@ -266,8 +265,6 @@ than announce state. ## Related -- [path-patterns](/quest/m1/auth/patterns.md) - owns the pattern dialect - and the shared matcher advertisements reuse - [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all pattern, and its catalog names the generations a wildcard cannot - [pop-skipping](/quest/m1/pop-skipping/README.md) - it owns the route cost and diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index f7ce89cfe0..7ea4acf06a 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -15,7 +15,9 @@ //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no //! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with -//! [`Error::Unsupported`]. +//! [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are; +//! moq-transport carries namespace prefixes, so it refuses a grant that is not a +//! union of subtrees rather than widen it. use std::{ collections::{BTreeMap, VecDeque}, diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index 01319d7352..bef39617a5 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -3,7 +3,7 @@ use std::time::Duration; use bytes::Bytes; use crate::coding::*; -use crate::{Path, Pattern, Patterns}; +use crate::{Pattern, Patterns}; use super::{Message, Version}; @@ -48,29 +48,27 @@ pub struct AuthOk { /// Largest millisecond count every implementation carries losslessly. const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; -/// Encode a grant's patterns as the wire's prefix list. -/// -/// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of -/// subtrees is representable. Anything else is refused rather than widened: -/// sending `room/**` for a grant of the literal `room/alice` would hand out more -/// than was granted. -fn encode_prefixes(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { +/// Encode a grant's patterns as their canonical text. +fn encode_patterns(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { patterns.len().encode(w, version)?; for pattern in patterns { - let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; - Path::new(prefix).encode(w, version)?; + pattern.as_str().encode(w, version)?; } Ok(()) } -fn decode_prefixes(r: &mut R, version: Version) -> Result { +fn decode_patterns(r: &mut R, version: Version) -> Result { let count = usize::decode(r, version)?; let mut patterns = Patterns::new(); // No preallocation: the count is peer-controlled, and the message size limit - // is what bounds how many prefixes actually fit. + // is what bounds how many patterns actually fit. for _ in 0..count { - let prefix = Path::decode(r, version)?; - let pattern = Pattern::subtree(prefix.as_str()).map_err(|_| DecodeError::InvalidValue)?; + let text = String::decode(r, version)?; + let pattern = Pattern::try_from(text.as_str()).map_err(|_| DecodeError::InvalidValue)?; + // Only the canonical spelling is valid, so each pattern has one encoding. + if pattern.as_str() != text { + return Err(DecodeError::InvalidValue); + } patterns.insert(pattern); } Ok(patterns) @@ -81,8 +79,8 @@ impl Message for AuthOk { if !version.has_auth() { return Err(DecodeError::Version); } - let publish = decode_prefixes(r, version)?; - let subscribe = decode_prefixes(r, version)?; + let publish = decode_patterns(r, version)?; + let subscribe = decode_patterns(r, version)?; let expires = match u64::decode(r, version)? { 0 => None, ms => Some(Duration::from_millis(ms)), @@ -98,8 +96,8 @@ impl Message for AuthOk { if !version.has_auth() { return Err(EncodeError::Version); } - encode_prefixes(&self.publish, w, version)?; - encode_prefixes(&self.subscribe, w, version)?; + encode_patterns(&self.publish, w, version)?; + encode_patterns(&self.subscribe, w, version)?; // 0 means never, so a grant that has already lapsed rounds up to the // smallest value that still reads as an expiry. let expires = match self.expires { @@ -196,8 +194,8 @@ impl Decode for AuthReply { mod tests { use super::*; - fn patterns(prefixes: &[&str]) -> Patterns { - prefixes.iter().map(|p| Pattern::subtree(p).unwrap()).collect() + fn patterns(texts: &[&str]) -> Patterns { + texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() } fn round_trip + Decode>(msg: &T) -> T { @@ -217,15 +215,16 @@ mod tests { } } - /// The empty prefix grants everything and the empty list grants nothing; both - /// spellings survive the trip distinctly. + /// `**` grants everything, the empty pattern only the root, and the empty list + /// nothing; literals and wildcards travel exactly, never widened to a prefix. #[test] fn auth_ok_round_trips() { for (publish, subscribe, expires) in [ - (patterns(&[""]), patterns(&[]), None), + (patterns(&["**"]), patterns(&[]), None), + (patterns(&[""]), patterns(&["room/**"]), None), ( - patterns(&["room/alice", "room/bob"]), - patterns(&["room"]), + patterns(&["room/alice", "room/*/cam", "**/demo.hang"]), + patterns(&["room/cam-*.hang", "lobby/**"]), Some(Duration::from_secs(60)), ), ] { @@ -236,7 +235,53 @@ mod tests { }); assert_eq!(round_trip(&msg), msg); } - assert_eq!(patterns(&[""]), Patterns::from(Pattern::all())); + } + + /// The exact bytes, shared with `js/net/src/lite/auth.test.ts` so both encoders agree. + #[test] + fn auth_ok_golden() { + let msg = AuthReply::Ok(AuthOk { + publish: patterns(&["room/*/cam", "**/b.hang"]), + subscribe: patterns(&[""]), + expires: Some(Duration::from_millis(1000)), + }); + let mut buf = bytes::BytesMut::new(); + msg.encode(&mut buf, Version::Lite06).unwrap(); + #[rustfmt::skip] + let want: &[u8] = &[ + 0x00, // AUTH_OK + 0x1a, // length + 0x02, // publish count, in canonical order + 0x09, b'*', b'*', b'/', b'b', b'.', b'h', b'a', b'n', b'g', + 0x0a, b'r', b'o', b'o', b'm', b'/', b'*', b'/', b'c', b'a', b'm', + 0x01, // subscribe count + 0x00, // the empty pattern: the root alone + 0x43, 0xe8, // expires: 1000ms + ]; + assert_eq!(&buf[..], want); + } + + /// Only valid, canonical text decodes: each pattern has exactly one encoding. + #[test] + fn invalid_patterns_are_refused() { + for text in ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"] { + let mut buf = bytes::BytesMut::new(); + AUTH_OK.encode(&mut buf, Version::Lite06).unwrap(); + let mut body = bytes::BytesMut::new(); + 1usize.encode(&mut body, Version::Lite06).unwrap(); + text.encode(&mut body, Version::Lite06).unwrap(); + 0usize.encode(&mut body, Version::Lite06).unwrap(); + 0u64.encode(&mut body, Version::Lite06).unwrap(); + body.len().encode(&mut buf, Version::Lite06).unwrap(); + buf.extend_from_slice(&body); + assert!( + matches!( + AuthReply::decode(&mut &buf[..], Version::Lite06), + Err(DecodeError::InvalidValue) + ), + "{text} decoded" + ); + } } #[test] @@ -262,24 +307,6 @@ mod tests { assert_eq!(got.expires, Some(Duration::from_millis(1))); } - /// Only subtrees fit the prefix encoding; anything narrower is refused, never - /// widened to its head. - #[test] - fn unrepresentable_grants_are_refused() { - for pattern in ["room/alice", "room/*/cam", "*/**"] { - let msg = AuthReply::Ok(AuthOk { - publish: Patterns::from(Pattern::try_from(pattern).unwrap()), - subscribe: Patterns::new(), - expires: None, - }); - let mut buf = bytes::BytesMut::new(); - assert!( - matches!(msg.encode(&mut buf, Version::Lite06), Err(EncodeError::Unsupported)), - "{pattern} encoded" - ); - } - } - #[test] fn older_versions_have_no_auth() { for version in [ diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 57e339192c..5858e48864 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -453,7 +453,7 @@ impl AuthServe { match state.outbox.pop_front() { Some(reply) => { drop(state); - Self::write(&self.shared, &issue, stream, reply)?; + Self::write(&self.shared, stream, reply)?; } // The app is done with the grant, or refused the token: close our side. None => { @@ -465,12 +465,7 @@ impl AuthServe { } } - fn write( - shared: &Shared, - issue: &kio::Shared, - stream: &mut Stream, - reply: crate::auth::Reply, - ) -> Result<(), Error> { + fn write(shared: &Shared, stream: &mut Stream, reply: crate::auth::Reply) -> Result<(), Error> { let msg = match reply { crate::auth::Reply::Grant(grant) => { let now = crate::runtime::Timers::now(&shared.runtime); @@ -485,18 +480,7 @@ impl AuthServe { reason, }), }; - match stream.writer.buffer(&msg) { - // This wire carries prefixes only, so a pattern grant cannot be told, only - // withheld: reset the stream, which the presenter reads as unsupported - // rather than refused. Never widen it. A pattern grant is routine until the - // wire carries patterns, so it is not worth a warning. - Err(Error::Encode(crate::coding::EncodeError::Unsupported)) => { - tracing::debug!("auth grant not representable as prefixes; resetting the token's stream"); - issue.lock().done = true; - Err(Error::Unsupported) - } - res => res, - } + stream.writer.buffer(&msg) } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index a7964daab5..2cd2592cd4 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -52,11 +52,37 @@ cases!( a_closed_session_holds_no_grant, a_reset_auth_stream_reports_unsupported, a_revoked_grant_cancels_its_subscriptions, + nothing_outside_the_grant_reaches_the_peer, +); + +/// Run each case on moq-transport alone, whose namespace prefixes cannot carry every +/// pattern. +macro_rules! prefix_cases { + ($($case:ident),* $(,)?) => { + mod moqt_17_prefixes { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22_prefixes { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_22).await })* + } + }; +} + +prefix_cases!( an_unrepresentable_grant_is_unsupported, an_unrepresentable_update_revokes_only_its_token, - nothing_outside_the_grant_reaches_the_peer, ); +#[tokio::test] +async fn lite_06_carries_pattern_grants() { + pattern_grants_arrive_exactly(LITE_06).await +} + +#[tokio::test] +async fn lite_06_enforces_a_wildcard_grant() { + a_wildcard_grant_is_enforced(LITE_06).await +} + #[tokio::test] async fn lite_05_has_no_grant() { older_versions_have_no_grant("moq-lite-05").await @@ -690,8 +716,8 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { .expect("timed out"); } -/// A grant the wire cannot carry as prefixes is never widened: the token is refused as -/// unsupported, promptly, and the union stays unknown rather than empty. +/// A grant moq-transport cannot carry as prefixes is never widened: the token is refused +/// as unsupported, promptly, and the union stays unknown rather than empty. async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { within(async { let mut pair = connect(Options { @@ -739,8 +765,8 @@ async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { .expect("timed out"); } -/// An update the wire cannot carry revokes that token's earlier grant, and only that -/// token's: the rest of the union and the session stay. +/// An update moq-transport cannot carry revokes that token's earlier grant, and only +/// that token's: the rest of the union and the session stay. async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) { within(async { let mut pair = connect(Options { @@ -766,7 +792,6 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) subscribe: Patterns::new(), expires: None, }); - // Lite resets the stream and moq-transport answers NOT_SUPPORTED; either ends it. t1.closed().await; assert_eq!(t1.grant().peek(), None); wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; @@ -776,6 +801,93 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) .expect("timed out"); } +fn pattern_set(texts: &[&str]) -> Patterns { + texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() +} + +/// Literal, wildcard, and mixed grants reach the presenter exactly as issued, never +/// widened to a covering prefix. +async fn pattern_grants_arrive_exactly(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let table = |token: &[u8]| -> Option { + let (publish, subscribe) = match token { + b"" => (pattern_set(&["a/**"]), pattern_set(&[])), + b"exact" => (pattern_set(&["room/alice"]), pattern_set(&[])), + b"wildcard" => (pattern_set(&["room/*/cam"]), pattern_set(&["**/demo.hang"])), + b"mixed" => (pattern_set(&["room/**", "lobby", "cam-*.hang"]), pattern_set(&[])), + b"root" => (pattern_set(&[""]), pattern_set(&["**"])), + _ => return None, + }; + Some(Grant { + publish, + subscribe, + expires: None, + }) + }; + let mut issued = serve(pair.requests.take().unwrap(), table); + let (_, _setup) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, table(b"").unwrap()); + + let mut held = Vec::new(); + for token in ["exact", "wildcard", "mixed", "root"] { + let added = pair.client.auth().add(token).await.expect(token); + assert_eq!(added.grant().peek(), table(token.as_bytes()), "{token}"); + held.push(added); + } + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A wildcard grant admits what it matches, including a leading `**` matching zero +/// segments, and a publish outside it still aborts naming the path. +async fn a_wildcard_grant_is_enforced(version: &'static str) { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let scope = pattern_set(&["room/*/cam", "**/b.hang"]); + let pair = connect(Options { + version: Some(version), + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.scope("", &scope).unwrap()), + ..Default::default() + }) + .await; + assert_eq!(granted(&pair.client).await.publish, scope); + + let mut held = Vec::new(); + for path in ["room/alice/cam", "b.hang", "deep/x/b.hang"] { + let broadcast = publisher.create_broadcast(path).unwrap(); + broadcast.announce(Default::default()).unwrap(); + wait_announced(&relay.consume(), path, true).await; + held.push(broadcast); + } + assert_eq!(pair.client_transport.close_reason(), None); + + let bad = publisher.create_broadcast("room/alice/mic").unwrap(); + bad.announce(Default::default()).unwrap(); + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + assert_eq!( + pair.client_transport.close_reason().map(|(_, reason)| reason), + Some("unauthorized: room/alice/mic".to_string()) + ); + }) + .await + .expect("timed out"); +} + /// The session closes before the peer ever hears of a broadcast outside the grant, /// even one published before the grant arrived: nothing is advertised until the /// setup token is answered. diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index a1ce23a99d..1789c1681b 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -817,6 +817,9 @@ impl FromStr for Pattern { return Self::new([]); } text.split('/') + // One past the limit is enough for `new` to refuse, without splitting a + // peer's megabytes of `a/a/...` into segments first. + .take(Self::MAX_SEGMENTS + 1) .map(Segment::parse) .collect::, _>>() .and_then(Self::new) diff --git a/rs/moq-pattern/tests/pattern.json b/rs/moq-pattern/tests/pattern.json index bf479d8c36..b05d65ae01 100644 --- a/rs/moq-pattern/tests/pattern.json +++ b/rs/moq-pattern/tests/pattern.json @@ -111,6 +111,10 @@ "text": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g", "error": "too-many-segments" }, + { + "text": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g/a*b*c", + "error": "too-many-segments" + }, { "text": "*.hang", "segments": [ diff --git a/test/interop/README.md b/test/interop/README.md index c3cd9fbd6b..c02211fda5 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -80,8 +80,10 @@ browser) runs once more with a token that excludes its broadcast. It must fail loud, logging Unauthorized and naming the path, and every subscriber must time out. -Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH\_OK carries prefixes: -the relay withholds a literal grant it cannot encode, and the client sees none. +Tokens use patterns no prefix could carry, so every cell checks that AUTH\_OK +delivers them as minted: a publisher is granted its exact broadcast, a subscriber +`**/name` (a leading `**` matching zero segments), and the refused publisher +`interop-allowed-*.hang`. ## Running locally diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 008ef5c14a..10de96c581 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -638,13 +638,15 @@ run_cell() { } # run_round : every subscriber dials with a -# token for alone, and must see data (want_pass=1) or time out (0). +# token for alone, and must see data (want_pass=1) or time out (0). The +# leading `**` matches zero segments, so the grant reaches every client as a pattern +# no prefix could carry. # names the publisher in the output and the logs. run_round() { local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" local pids=() names=() i sub sub_url sub_grant why - sub_url=$(token_url --subscribe "$broadcast/**") - sub_grant=$(grant_line "" "$broadcast/**") + sub_url=$(token_url --subscribe "**/$broadcast") + sub_grant=$(grant_line "" "**/$broadcast") for sub in "${SUB_LIST[@]}"; do if is_broken "$sub"; then echo " FAIL $pub -> $sub (subscriber client unavailable)" @@ -761,9 +763,10 @@ else overall=1 continue fi - start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast/**")" + # The exact broadcast, not its subtree. + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast")" run_round "$pub" "$broadcast" "$PUB_PID" 1 - if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast/**" "")"); then + if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast" "")"); then prints_grant "$pub" && echo " PASS $pub grant" else echo " FAIL $pub $why" @@ -776,16 +779,16 @@ else for pub in "${PUB_LIST[@]}"; do if ! enforces_grant "$pub" || is_broken "$pub"; then continue; fi broadcast="interop-denied-${pub}-$$-${RANDOM}.hang" - allowed="interop-allowed-$$" - echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed/**) ===" - start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed/**")" + allowed="interop-allowed-*.hang" + echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed) ===" + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed")" run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 log="$HARNESS_RUN/pub-$pub-denied.log" if ! check_denied "$log" "$broadcast"; then echo " FAIL $pub publisher did not fail with Unauthorized naming $broadcast:" sed 's/^/ /' "$log" 2>/dev/null || true overall=1 - elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed/**" "")"); then + elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed" "")"); then echo " FAIL $pub $why" overall=1 else diff --git a/test/ts/relay.toml b/test/ts/relay.toml new file mode 100644 index 0000000000..b2e55c9d06 --- /dev/null +++ b/test/ts/relay.toml @@ -0,0 +1,21 @@ +# Relay config for the TS compliance round-trip. +# Anonymous access, self-signed localhost cert, QUIC + HTTP on 127.0.0.1. +# +# run.sh overrides the ports on the command line (`--listen` / +# `--web-http-listen`) with the one it reserved. + +[log] +level = "info" + +[listen] +# QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. +bind = "127.0.0.1:4470" +tls.generate = ["localhost", "127.0.0.1"] + +[web.http] +# HTTP on TCP, serving /certificate.sha256 for cert pinning. +listen = "127.0.0.1:4470" + +[auth] +# Allow anonymous access to everything. +public = "**" diff --git a/test/ts/run.sh b/test/ts/run.sh index 1b3ad5b0aa..d66df1e0d6 100755 --- a/test/ts/run.sh +++ b/test/ts/run.sh @@ -264,8 +264,8 @@ if harness_probe "$URL/certificate.sha256"; then fi echo "### starting relay on 127.0.0.1:${PORT}" -sed "s/4443/${PORT}/g" "$DIR/../interop/interop.toml" >"$HARNESS_RUN/relay.toml" -harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$HARNESS_RUN/relay.toml" +harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$DIR/relay.toml" \ + --listen "127.0.0.1:${PORT}" --web-http-listen "127.0.0.1:${PORT}" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then echo "error: relay never became ready" >&2 sed 's/^/ relay: /' "$HARNESS_RUN/relay.log" >&2 || true