diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 90018cfcd3..f67da9f16b 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -143,7 +143,11 @@ if its first credential expired earlier, after its attached grants stop. Each attached JWT counts against its own `--limit-token` cap until the connection ends. The initial credential remains responsible for the connection's stats tier. Certificate sessions cannot renew a certificate in band, and public rules -cannot verify a presented token; both refuse it as unsupported. A burst of +cannot verify a presented token; both refuse it as unsupported. A moq-transport +`AUTHORIZATION TOKEN` on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified the +same way on any draft, but its grant authorizes only that request and ends with +it; it never joins the union. A `PUBLISH_NAMESPACE` grant must cover the whole +namespace: `room/**`, not just `room`. A burst of tokens past the session's pending-verification bound is refused. On moq-lite, an auth server that cannot answer, by timeout or outage, refuses a token as a transient error, and `Connection::auth()` presents it again on its reconnect diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 6cf9fa1467..c166cb2ec2 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,8 +29,8 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** In Rust, Object ID gaps must be marked by Prior Object ID Gap properties for a group to be fetchable. A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed, and so does a reader whose publisher refuses `TRACK_STATUS` as `NOT_SUPPORTED`. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. -- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. A token attached to an individual request is otherwise decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` is refused unread, so its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. -- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority; any other update ends that subscription. +- **One `SETUP` credential**, forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second `SETUP` token closes the session. We advertise no token cache, so an alias reference in `SETUP` closes it with `PROTOCOL_VIOLATION`, one on a request with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. Where the Rust side authorizes the peer or verifies tokens, a token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where that authorization already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when nothing verifies it, when it is empty, or when its Token Type is anything but 0 (out of band). A side that does neither, JavaScript included, ignores the token. Rust refuses a request carrying more than one distinct token with `NOT_SUPPORTED`, since it verifies one; JavaScript, which verifies none, ignores them all. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. A token-authorized `PUBLISH_NAMESPACE` needs a grant covering everything beneath the namespace, and the tracks we subscribe under it rely on that announce instead of our limit, ending with it. An update renews the token, except on a drafts 14 and 15 `PUBLISH_NAMESPACE`, which has no update, and a refused renewal ends only that request. A token on any other request is decoded and grants nothing, and a request repeating one is refused with `MALFORMED_AUTH_TOKEN`. `SUBSCRIBE_TRACKS` is refused unread, so its token is not decoded. On draft 17 and later, the negotiated [MoQ Auth extension](/concept/moq-lite#authorization) accepts additional session tokens in band. +- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority or renew a request token; any other update ends that subscription. - **Objects and datagrams.** Rust relays preserve Object IDs, gaps, object properties, FIRST\_OBJECT, and END\_OF\_GROUP status objects between moq-transport peers for subgroup 0. Datagrams retain their IDs, properties, priority, and status. Plain Rust readers and moq-lite peers see consecutive frames and normal datagram payloads; JavaScript supports only normal datagrams at Object ID 0. A malformed datagram closes the session; a relayed one the downstream draft cannot carry is dropped. A relayed Timestamp leaves in the units the downstream response declares, and is dropped where it declares none (drafts 14 through 16), as for a plain frame; an immutable one that would change or vanish is refused. Property 0x06 is a draft-ietf-moq-loc-03 Timestamp only before draft 19, which reassigns it to SUBGROUP\_DELIVERY\_TIMEOUT, so it is dropped across that boundary, or refused when immutable. From draft 15, a status object carrying properties is refused, and one relayed from draft 14 leaves without them. A `FETCH` carries the END\_OF\_GROUP object on drafts 14 and 15; later drafts have no FETCH status, so their response ends at the last frame. A group cannot resume across route changes by frame ordinal once Object IDs skip ahead of the ordinals on either route; recovery refuses it instead of splicing different objects. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. - **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's. diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 28d52a523f..1c43d57a6c 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -301,6 +301,8 @@ Sent when terminating the session, via the transport's session close. | ------- | ------------- | ----------- | | 0x17 | UNKNOWN_AUTH_TOKEN_ALIAS | A token named an alias that was never registered. | | ------- | ------------- | ----------- | +| 0x1B | TOO_MANY_REQUEST_UPDATES | The peer left more request updates unanswered than the endpoint allows. | +| ------- | ------------- | ----------- | | 0x30 | NOT_SUPPORTED | The requested feature, token, or grant is unsupported. | | ------- | ------------- | ----------- | @@ -1590,7 +1592,7 @@ The `Message Length` describes the payload size on the wire. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. - Capped the Message Length of every message except FRAME at 65,535 bytes. - Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements. -- Added the AUTH_TOKEN_CACHE_OVERFLOW (0x13) and UNKNOWN_AUTH_TOKEN_ALIAS (0x17) session codes, with moq-transport's meaning. +- Added the AUTH_TOKEN_CACHE_OVERFLOW (0x13), UNKNOWN_AUTH_TOKEN_ALIAS (0x17), and TOO_MANY_REQUEST_UPDATES (0x1B) session codes, with moq-transport's meaning. - Limited AUTH_OK Expires to 2^53 - 1 milliseconds, with an out-of-range value closing the session with PROTOCOL_VIOLATION. - Invalid UTF-8 or an oversized AUTH_ERROR reason closes the session with PROTOCOL_VIOLATION. - An Auth Stream message that fails to decode, has trailing body bytes, or has an unexpected type, or a second AUTH from the opener, closes the session with PROTOCOL_VIOLATION. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 3ce563b47f..01f769a0b3 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -61,46 +61,6 @@ async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginP // Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { - test("pending application tokens have a per-session bound", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol }); - try { - const requests = server.auth.requests(); - const setup = await requests.next(); - if (!setup) throw new Error("setup token missing"); - setup.accept(grant(["a"], [])); - let refused!: () => void; - const overflow = new Promise((resolve) => { - refused = resolve; - }); - const pending = Array.from({ length: 129 }, (_, index) => - client.auth.add(`token-${index}`).then( - (token) => ({ token }), - (err: unknown) => { - refused(); - return { err }; - }, - ), - ); - await withTimeout(overflow, 1000, "pending token bound did not refuse overflow"); - for (let index = 0; index < 128; index++) { - const request = await requests.next(); - if (!request) throw new Error("pending token missing"); - request.accept(grant(["a"], [])); - } - const results = await Promise.all(pending); - const errors = results.flatMap((result) => ("err" in result ? [result.err] : [])); - expect(errors).toHaveLength(1); - // moq-transport's AUTH_ERROR registry only distinguishes unsupported from unauthorized. - expect((errors[0] as SessionError).code).toBe( - protocol === Lite.ALPN_07_WIP ? SessionCode.TooManyRequests : SessionCode.Unauthorized, - ); - for (const result of results) if ("token" in result) result.token.close(); - } finally { - client.abort(); - server.abort(); - } - }); - test("both sides learn their default grant", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -497,7 +457,7 @@ test("withdrawn queued tokens are pruned before the next application decision", if (!setup) throw new Error("setup token missing"); setup.accept(grant(["a"], [])); const wire = new LiteAuthWire(transport, Lite.Version.DRAFT_07); - for (let index = 0; index < 128; index++) { + for (let index = 0; index < 3; index++) { const stream = await wire.present(new TextEncoder().encode("withdrawn")); await stream.writer.close(); await stream.reader.closed; diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 75d1124821..054a1a43e5 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -111,6 +111,9 @@ export interface Auth { * * Call it as soon as connect or accept resolves, in the same task: whoever answers is * decided once, right after. Throws once that has happened. + * + * The peer's stream credit bounds how many tokens wait unanswered, since each holds its + * stream open until answered or withdrawn. A withdrawn token leaves the queue. */ requests(): Requests; } diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 696bd01f58..493703623f 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -213,19 +213,23 @@ class IssuedGrant implements Issued { /** The peer's tokens, queued for the application. */ class RequestQueue implements Requests { #queue: PeerRequest[] = []; + // How many requests survived the last prune: pruning again only once the queue doubles + // keeps a push amortized O(1). + #survivors = 0; #waiters: ((request: PeerRequest | undefined) => void)[] = []; #closed = false; - /** Queue the token for the application, or refuse it when closed or full. */ + /** Queue the token for the application, or refuse it when closed. */ push(request: PeerRequest): void { if (this.#closed) { request.reject(SessionCode.Unauthorized, "not accepting tokens"); return; } - if (this.#queue.length >= 128) this.#queue = this.#queue.filter((queued) => !queued.ended); - if (this.#queue.length >= 128) { - request.reject(SessionCode.TooManyRequests, "too many pending tokens"); - return; + // A withdrawn token gave its stream back, so it must not stay queued while the + // application is busy: that would let a peer that keeps withdrawing grow the queue. + if (this.#queue.length >= 2 * Math.max(this.#survivors, 1)) { + this.#queue = this.#queue.filter((queued) => !queued.ended); + this.#survivors = this.#queue.length; } const waiter = this.#waiters.shift(); if (waiter) waiter(request); diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts index d33c6f00f1..f101b467d3 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -217,6 +217,7 @@ test("the code tables match the spec", () => { expect(Number(SessionCode.AuthTokenCacheOverflow)).toBe(0x13); expect(Number(SessionCode.Version)).toBe(0x15); expect(Number(SessionCode.UnknownAuthTokenAlias)).toBe(0x17); + expect(Number(SessionCode.TooManyRequestUpdates)).toBe(0x1b); expect(Number(StreamCode.Cancel)).toBe(0x1); expect(Number(StreamCode.SessionClosed)).toBe(0x3); expect(Number(StreamCode.TooFarBehind)).toBe(0x5); diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 1a28718620..56ee12b426 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -54,6 +54,8 @@ export const SessionCode = Object.freeze( UnknownAuthTokenAlias: 0x17 as SessionCode, /** The peer cannot support the requested feature; moq-lite only. */ Unsupported: 0x30 as SessionCode, + /** The peer left more request updates unanswered than MAX_REQUEST_UPDATES allows. */ + TooManyRequestUpdates: 0x1b as SessionCode, } as const), ); diff --git a/js/net/src/ietf/cluster.test.ts b/js/net/src/ietf/cluster.test.ts index 86082fc7e5..80cf431fa1 100644 --- a/js/net/src/ietf/cluster.test.ts +++ b/js/net/src/ietf/cluster.test.ts @@ -283,12 +283,27 @@ test("Cluster: every malformed PUBLISH_NAMESPACE update is a protocol violation" await expect(PublishNamespaceUpdate.decode(reader(bytes), VERSION)).rejects.toThrow(ProtocolViolation); } - // The message did not exist before draft-17. + // The message did not exist before draft-16. await expect( - PublishNamespaceUpdate.decode(reader(new Uint8Array([0, 0]), Version.DRAFT_16), Version.DRAFT_16), + PublishNamespaceUpdate.decode(reader(new Uint8Array([0, 0]), Version.DRAFT_15), Version.DRAFT_15), ).rejects.toThrow(ProtocolViolation); }); +test("Cluster: a draft-16 PUBLISH_NAMESPACE update names the advertisement", async () => { + const version = Version.DRAFT_16; + const update = new PublishNamespaceUpdate({ requestId: 2n, namespaceRequestId: 0n, update: {} }); + const decoded = await PublishNamespaceUpdate.decode(reader(await encode(update, version), version), version); + expect(decoded.requestId).toBe(2n); + expect(decoded.namespaceRequestId).toBe(0n); + + // A repeated token refuses the update, keyed by its own Request ID. + const tokens = [2, 0x03, 3, 0x03, 0x00, 0xaa, 0, 3, 0x03, 0x00, 0xaa]; + const body = [2, 0, ...tokens]; + const repeated = PublishNamespaceUpdate.decode(reader(new Uint8Array([0, body.length, ...body]), version), version); + await expect(repeated).rejects.toThrow(MalformedAuthToken); + await expect(repeated).rejects.toMatchObject({ requestId: 2n }); +}); + test("Cluster: a reset while decoding an update stays stream-local", async () => { const reset = Object.assign(new Error("reset"), { source: "stream" as const, diff --git a/js/net/src/ietf/publish_namespace.ts b/js/net/src/ietf/publish_namespace.ts index 144e2cf66c..5c9c6981dd 100644 --- a/js/net/src/ietf/publish_namespace.ts +++ b/js/net/src/ietf/publish_namespace.ts @@ -84,16 +84,33 @@ export class PublishNamespaceUpdate { /** The update's own Request ID; every REQUEST_UPDATE consumes one. */ requestId: bigint; + /** Draft-16 only: the PUBLISH_NAMESPACE this updates, which later drafts name by stream. */ + namespaceRequestId?: bigint; update: Cluster.Update; - constructor({ requestId, update }: { requestId: bigint; update: Cluster.Update }) { + constructor({ + requestId, + namespaceRequestId, + update, + }: { + requestId: bigint; + namespaceRequestId?: bigint; + update: Cluster.Update; + }) { this.requestId = requestId; + this.namespaceRequestId = namespaceRequestId; this.update = update; } async #encode(w: Writer, version: IetfVersion): Promise { - PublishNamespaceUpdate.#modern(version); + PublishNamespaceUpdate.#supported(version); await w.u62(this.requestId); + if (version === Version.DRAFT_16) { + if (this.namespaceRequestId === undefined) { + throw new Error("a draft-16 REQUEST_UPDATE names its PUBLISH_NAMESPACE"); + } + await w.u62(this.namespaceRequestId); + } if (version === Version.DRAFT_17) { await w.u62(0n); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) } @@ -128,20 +145,21 @@ export class PublishNamespaceUpdate { } } - static #modern(version: IetfVersion) { - if (version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16) { - throw new Error("REQUEST_UPDATE on PUBLISH_NAMESPACE requires draft-17+"); + static #supported(version: IetfVersion) { + if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { + throw new Error("REQUEST_UPDATE on PUBLISH_NAMESPACE requires draft-16+"); } } static async #decode(r: Reader, version: IetfVersion): Promise { - PublishNamespaceUpdate.#modern(version); + PublishNamespaceUpdate.#supported(version); const requestId = await r.u62(); + const namespaceRequestId = version === Version.DRAFT_16 ? await r.u62() : undefined; if (version === Version.DRAFT_17) { await r.u62(); // required_request_id_delta (draft-17 only, removed in draft-18 per #1615) } const params = await Parameters.decode(r, version, "publish-namespace"); - return new PublishNamespaceUpdate({ requestId, update: Cluster.updateFromParams(params) }); + return new PublishNamespaceUpdate({ requestId, namespaceRequestId, update: Cluster.updateFromParams(params) }); } } diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index a1cd3924c6..0f1596f8c6 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -16,7 +16,7 @@ import { Connection } from "./connection.ts"; import { ObjectDatagram } from "./datagram.ts"; import { encodeObjectExtensions, type GroupFlags, Group as GroupMessage } from "./object.ts"; import type { Properties } from "./properties.ts"; -import { PublishNamespace, PublishNamespaceUpdate } from "./publish_namespace.ts"; +import { PublishNamespace, PublishNamespaceCancel, PublishNamespaceUpdate } from "./publish_namespace.ts"; import { MaxRequestId, RequestError, RequestOk } from "./request.ts"; import { Subscribe, SubscribeOk, Unsubscribe } from "./subscribe.ts"; import { SubscribeNamespace, SubscribeNamespaceEntry, SubscribeNamespaceEntryDone } from "./subscribe_namespace.ts"; @@ -678,6 +678,48 @@ test("a PUBLISH_NAMESPACE update that starts looping back is detached", async () await handler; }); +/** + * Draft-16 renews a PUBLISH_NAMESPACE with a REQUEST_UPDATE naming it, answered by the + * update's own Request ID. One repeating a token is refused that way too, and cancels the + * advertisement, which outlives its stream on that draft. + */ +test("a draft-16 PUBLISH_NAMESPACE update is answered by its own Request ID", async () => { + const version = Version.DRAFT_16; + const pair = createMockTransportPair(ALPN.DRAFT_16); + const session = new NativeSession(pair.server, version, true); + const subscriber = new Subscriber({ session }); + + const request = await Stream.open(pair.server, { version }); + const handler = subscriber.runPublishNamespace( + new PublishNamespace({ requestId: 1n, trackNamespace: Path.from("theirs") }), + request, + ); + const peer = await nextStream(pair.client); + if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); + expect(await peer.reader.u53()).toBe(RequestOk.id); + expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(1n); + + await peer.writer.u53(PublishNamespaceUpdate.id); + await new PublishNamespaceUpdate({ requestId: 3n, namespaceRequestId: 1n, update: {} }).encode( + peer.writer, + version, + ); + expect(await peer.reader.u53()).toBe(RequestOk.id); + expect((await RequestOk.decode(peer.reader, version)).requestId).toBe(3n); + + // Two identical AUTHORIZATION TOKENs. + const body = [5, 1, 2, 0x03, 3, 0x03, 0x00, 0xaa, 0, 3, 0x03, 0x00, 0xaa]; + await peer.writer.u53(PublishNamespaceUpdate.id); + await peer.writer.u16(body.length); + for (const byte of body) await peer.writer.u8(byte); + expect(await peer.reader.u53()).toBe(RequestError.id); + expect((await RequestError.decode(peer.reader, version)).requestId).toBe(5n); + expect(await peer.reader.u53()).toBe(PublishNamespaceCancel.id); + expect((await PublishNamespaceCancel.decode(peer.reader, version)).requestId).toBe(1n); + + await handler; +}); + /** * REQUEST_UPDATE keeps an omitted parameter, so an explicit ROUTE_COST of 0 lands on the * path already held without disturbing the announcement. Consumers hear `update` with the diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 0882fa01e1..0b8564312a 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -38,6 +38,7 @@ import { fromWire, toWire } from "./priority.ts"; import { type Publish, PublishDone, PublishError, publishDoneClean } from "./publish.ts"; import { type PublishNamespace, + PublishNamespaceCancel, PublishNamespaceDone, PublishNamespaceError, PublishNamespaceOk, @@ -1089,24 +1090,38 @@ export class Subscriber { ); } + // Draft-16 updates ride the control stream, which already spent their own Request + // ID and keys their answer by it. + const keyed = version === Version.DRAFT_16; let update: PublishNamespaceUpdate; try { update = await PublishNamespaceUpdate.decode(stream.reader, version); } catch (err) { if (!(err instanceof MalformedAuthToken)) throw err; // A refused update still spends its own request ID. - if (err.requestId !== undefined) this.#session.spendRequest(err.requestId); + if (!keyed && err.requestId !== undefined) this.#session.spendRequest(err.requestId); // Section 8.9 rejects the update, which withdraws the advertisement as any - // refused update does. Only draft-17+ updates arrive here, so no Request ID. + // refused update does. + const errorCode = toRequestCode("malformed_auth_token", "publish_namespace", version); await stream.writer.u53(RequestError.id); await new RequestError({ - errorCode: toRequestCode("malformed_auth_token", "publish_namespace", version), + requestId: keyed ? err.requestId : undefined, + errorCode, reasonPhrase: err.message, }).encode(stream.writer, version); + // Draft-16's advertisement outlives its stream, so withdraw it explicitly. + if (keyed) { + await stream.writer.u53(PublishNamespaceCancel.id); + await new PublishNamespaceCancel({ + requestId: msg.requestId, + errorCode, + reasonPhrase: err.message, + }).encode(stream.writer, version); + } stream.close(); break; } - this.#session.spendRequest(update.requestId); + if (!keyed) this.#session.spendRequest(update.requestId); // The parameters exist only on a session that negotiated the extension; // anywhere else they are the peer's violation. @@ -1141,7 +1156,7 @@ export class Subscriber { // routes nothing, so a repricing changes nothing it holds; consumers still // hear the new route so a forwarder can reprice. await stream.writer.u53(RequestOk.id); - await new RequestOk({}).encode(stream.writer, version); + await new RequestOk({ requestId: keyed ? update.requestId : undefined }).encode(stream.writer, version); } } finally { if (legacy) this.#legacyRequests.delete(path); diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index ca501555e8..8e7dcef926 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -23,17 +23,38 @@ that predates the stream. Node-bound peer grants for direct sessions belong to Decisions settled while planning, recorded so review does not relitigate them: -- **Per-request boundary (2026-10-12).** Request tokens are an experimental - Rust interoperability hook with application-owned verification, retaining - renewal. They are not a complete authentication scheme or a relay feature. - Local session permission may admit an incoming request without a token, - but a token on a request is always verified; grants received from the peer - never admit one. Request tokens arrive on their own opt-in `scoped()` queue - beside the unchanged session-token `requests()` (revised on #5148). - Per-request relay integration, JS APIs, CLI, and bindings are deferred; - session AUTH work continues. Document the hook only in minimal API comments, - with no feature page or guide. The relay request-lease quest is related - future work, no longer required to complete this epic. +- ✅ Decided 2026-10-15 (maintainer, #5148): **one kind of auth.** A token + is presented, verified by the acceptor through `requests()`, and earns a + grant with a lifetime. An AUTH stream's token lives for the session and + its grant joins the session scope. A moq-transport `AUTHORIZATION TOKEN` + on a request is the same kind of auth, named by that request: it admits + only that request, never joins the scope, and ends with it. Renewal on + SUBSCRIBE_UPDATE or REQUEST_UPDATE names a new auth that replaces the old. + Per-request tokens are moq-transport's naive form and are not surfaced as + a separate API. +- ✅ Decided 2026-10-15 (maintainer, #5148): an unnamed request is admitted + by the union of session-lifetime auths only. A named request outside its + auth's grant is refused, and ends when that auth ends. +- ✅ Decided 2026-10-15 (maintainer, #5148): the work lands in steps. + Step 1 (#5148) is incoming only: request tokens arrive on `requests()`, + named requests are held to their issued grant, renewal is renaming, and + no lite wire changes. Step 2 specs `auth=id` in the wip lite version so a + lite request names an AUTH stream instead of carrying a token. Step 3 + adds outgoing request tokens through `add()` with automatic naming, sent + as `USE_VALUE` on plain moq-transport. +- ✅ Decided 2026-10-15 (maintainer, #5148): `auth::Handle::authorize` + keeps its name and now means the connection credential's grant; the + session adds every grant it issued to an AUTH token itself. +- ✅ Decided 2026-10-15 (maintainer, #5148): a typed (CAT etc.) or empty + request token is refused `NOT_SUPPORTED`. An AUTH token has no type, and + an empty one means the connection's own credential. +- ✅ Decided 2026-10-15 (maintainer, #5148): moq-relay verifies request + tokens in band like any token; ending a request token's lease with its + request at the auth server stays in + [Request leases](/quest/m3/auth-request-lease.md). +- ✅ Decided 2026-10-15 (maintainer, #5148): no cap on pending tokens. The + peer's credit bounds them: each AUTH token holds its stream open, and each + request token its request's slot, until answered or withdrawn. - **One AUTH stream per token, opened by whoever presents it.** Either side of a session may open one; the acceptor answers with the grant that token earns. A moq-net session opens one on both sides right after SETUP with an @@ -99,8 +120,7 @@ Decisions settled while planning, recorded so review does not relitigate them: SUBSCRIBE_OK. Rust is `moq_net::auth` with `auth::Grant`, `auth::Handle`, `auth::Token`, and `auth::Request`; JS mirrors as `connection.auth`. -`Session::auth()` is additive, and so is the request-token hook: its opt-in -`scoped()` queue sits beside the unchanged `requests()`. The relay derives the grant +`Session::auth()` is additive. The relay derives the grant from the origin handles it already scopes. Decided 2026-10-05: wire work targets the wip lite version until the maintainer cuts it, never a published version in place, so AUTH lands in `moq-lite-07-wip` (or whichever @@ -110,8 +130,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required -- [Request tokens](/quest/m1/auth/request-token.md) - an experimental Rust - verifier hook confines a token's authority to its request and renews it - [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route or subscription swaps its auth and the session sends the update, keeping the request up - [Requests name their auth](/quest/m1/auth/request-auth-id.md) - lite-07 AUTH carries an ID and each request names the auth that admits it - [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is @@ -125,16 +143,11 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. the rest, with the URL kept for peers without the AUTH stream - [Join revocation](/quest/m1/auth/join-revocation.md) - a revoked grant ends our subscription even while its joining FETCH waits for request-ID credit -- [Enforcement cursor](/quest/m1/auth/enforcement-cursor.md) - outgoing announce - enforcement reads the origin through the announcer, so an End-to-Start race - fails loud instead of withholding silently -- [Concurrent renewal](/quest/m1/auth/concurrent-renewal.md) - a token refresh - renews every live namespace at once, benchmarked over namespaces and sessions ## Related -- [Request leases](/quest/m3/auth-request-lease.md) - deferred relay - integration, to revisit when a consumer needs per-request verification +- [Request leases](/quest/m3/auth-request-lease.md) - deferred auth-server + accounting that ends a request token's lease with its request - [Request tokens beyond Rust](/quest/m3/request-token-surfaces.md) - JS, moq-ffi, and moq-cli request-token APIs, when a consumer needs one - [Token on FETCH](/quest/m3/auth-token-fetch.md) - TRACK_STATUS and FETCH carry diff --git a/quest/m1/auth/concurrent-renewal.md b/quest/m1/auth/concurrent-renewal.md deleted file mode 100644 index 5109626cff..0000000000 --- a/quest/m1/auth/concurrent-renewal.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] A token refresh renews every live namespace at once - -## Goal - -Replacing the request token renews all of a session's token-backed -PUBLISH_NAMESPACE advertisements concurrently, as far as the peer's request-ID -credit allows. With enough credit, the last namespace is renewed about one -round trip after the refresh, not one round trip per namespace. The -session's other announce work does not stall behind the renewals. A -benchmark swept over namespaces and sessions shows the refresh cost growing -only with the namespaces touched. - -## Plan - -Deferred from #5148 by the maintainer, with a benchmark sweep. At #5148's -head (`ed595beef`), `refresh_request_token` (`rs/moq-net/src/ietf/publisher.rs`) -runs inside the publisher's namespace loop. For each token-backed -advertisement in turn, it takes a request ID, writes the update, and waits -for the answer. So with N advertisements the last one is renewed about N -round trips after the refresh. If the old grant lapses inside that window, -the peer ends the late advertisements even though the refresh succeeded. -Meanwhile, new announcements and withdrawals wait behind the whole loop. - -Root problem: renewal is awaited inline in the loop that serves every -namespace event. Make each advertisement's renewal its own in-flight step, -polled beside the loop's other events, as #5148 did for subscription renewal. - -Keep the contracts #5148 settled: - -- At most one renewal in flight per advertisement, superseded by a newer - token while it waits for credit. -- A refusal or cancel ends only that advertisement and re-offers it with the - new token. -- Request IDs reach the wire in allocation order. Drafts 14-16 require the - next sequential ID (`Control::accept`), and a concurrent sender must not - let a later ID overtake an earlier one, as the review of #5148 found for - FETCH and subscription renewals. -- Request-ID credit bounds how many renewals start at once. Add no new cap. - -Benchmark in `rs/moq-net/benches` (`auth.rs` or `announce.rs`). Sweep live -token-backed namespaces against sessions sharing an origin. A refresh's cost -should grow with the namespaces it renews, not with the other sessions'. -Tests on mocked time with a fixed peer round trip: - -- When the peer grants request-ID credit for every live advertisement, every - renewal answer arrives within about one round trip of the refresh. -- When credit is short, renewals stay within it and go out in allocation - order. Later ones start only as credit returns, and none bypasses the limit. -- An announcement made during a refresh goes out without waiting for it. - -Public API: none. Wire: none, only the timing of the existing updates. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - #5148 adds the renewal this changes diff --git a/quest/m1/auth/enforcement-cursor.md b/quest/m1/auth/enforcement-cursor.md deleted file mode 100644 index 321b779546..0000000000 --- a/quest/m1/auth/enforcement-cursor.md +++ /dev/null @@ -1,50 +0,0 @@ -# [M] Grant enforcement reads the origin through the announcer - -## Goal - -Outgoing announce enforcement decides on exactly the advertisements the -publisher sends, with the token each one actually carried, on one origin -cursor. The race an OpenAI review found on #5148 then fails loud: clear the -request token, then end and re-announce the same uncovered path within one -driver turn. Today the new announcement is silently withheld. After this -change, the session closes `Unauthorized`, as it does for any other uncovered -announcement. - -## Plan - -Deferred from #5148 (review 4275208063, declined there as a structural -change). At #5148's head (`ed595beef`), two loops read the origin's -announcements through independent cursors. One is grant enforcement -(`enforce_grant` in `rs/moq-net/src/ietf/session.rs`), which closes the -session on an uncovered announcement unless a request token backs it. The -other is the publisher's namespace loop, which sends PUBLISH_NAMESPACE and -knows which advertisement carries a token. Enforcement runs first in each -driver turn. On End then Start of the same path, it still finds the old -advertisement's token backing by path and admits the new Start. The -publisher then withdraws the old request and filters out the tokenless new -offer. Nothing unauthorized reaches the wire, but the fail-loud contract -breaks. Every finding in this area on #5148 (the clear race in 4274905268, -this End-to-Start race) traces back to the two cursors. - -Root problem: two readers of one event stream must agree on per-announcement -state, and nothing ties them together. Decide enforcement inside the -publisher's namespace loop, where each advertisement's token backing is -already bound to that advertisement's lifetime, and delete the second -cursor. Keep the contracts #5148 settled: a token stands enforcement down for -the advertisements it backs, clearing the token resumes enforcement only for -later announcements, and a live token-backed advertisement keeps standing -through reprices. - -Check whether the lite publisher has the same split between its -enforcement and announce cursors. If it does, fix it the same way here. - -Regression tests: the End-to-Start case above closes the session -`Unauthorized`. Keep the existing tests that #5148 added around clearing the -token (`a_token_cleared_behind_a_presented_announce_keeps_the_session` and -its neighbors) passing. - -Public API: none. Wire: none. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - #5148 adds the token backing this enforcement reads diff --git a/quest/m1/auth/join-revocation.md b/quest/m1/auth/join-revocation.md index 10d376566c..fe35c3bc91 100644 --- a/quest/m1/auth/join-revocation.md +++ b/quest/m1/auth/join-revocation.md @@ -8,15 +8,13 @@ even while its joining FETCH waits for request-ID credit or a stream. ## Plan -A #5148 follow-up. At #5148's head (`ed595beef`), after SUBSCRIBE_OK the +Found on #5148. After SUBSCRIBE_OK the subscriber sends its joining FETCH through `start_joining_fetch` (`rs/moq-net/src/ietf/subscriber.rs`). That function awaits `next_request_id`, which gives up after ten seconds without credit, and then a stream open. Only then does the subscription loop start polling its gate -and terminals. A revocation in that window waits for the credit wait to end. -#5148 fixed the same shape for token renewal: it polls the wait alongside the -subscription's terminals, so a peer that withholds credit cannot hold a -revocation back. +and terminals. A revocation in that window waits for the credit wait to end, so a peer +that withholds credit can hold a revocation back. Root problem: a step of the subscription's setup runs outside the loop that races the grant. Bring the joining FETCH's credit wait and open under the same @@ -29,7 +27,3 @@ not after the ten-second give-up. Check every draft's setup path for any other await that is not under the gate. Public API: none. Wire: none. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - #5148 adds the grant-aware subscription loop this extends diff --git a/quest/m1/auth/request-auth-id.md b/quest/m1/auth/request-auth-id.md index 5f888c4851..dec288e79a 100644 --- a/quest/m1/auth/request-auth-id.md +++ b/quest/m1/auth/request-auth-id.md @@ -14,11 +14,9 @@ for moq-transport, additively. Step 2 of the unified auth model settled on [#5148](https://github.com/moq-dev/moq/pull/5148) on 2026-10-15. Step 1 -(#5148, [Request tokens](/quest/m1/auth/request-token.md)) makes a -moq-transport request token an auth on the shared `requests()` queue. -#5148 is rewriting that quest and the auth README from its older `scoped()` -queue and `set_request_token` design to this unified model; read it there -until it lands. +(#5148) makes a moq-transport request token an auth on the shared +`requests()` queue; the decisions are recorded in the +[auth README](/quest/m1/auth/README.md). - ✅ Decided 2026-10-15 (maintainer): one kind of auth. Each request names at most one (`auth=id`); moq-transport's per-request token is only an encoding @@ -57,11 +55,7 @@ until it lands. `drafts/draft-lcurley-moq-auth.md`, Rust and `js/net`, and run `just test interop --all`. Update `doc/concept/moq-lite.md`'s Authorization section (scope is no longer only the session union) and - `doc/concept/standard.md`, which says a request's token grants nothing. + `doc/concept/standard.md`, which describes only moq-transport request tokens. Public API: none beyond step 1's `requests()` and `add()`; naming is automatic. Wire: lite-07-wip AUTH and six request messages gain fields. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - step 1: request tokens become auths on the shared acceptor queue diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md index f4ab96c9af..b520465184 100644 --- a/quest/m1/auth/request-gate.md +++ b/quest/m1/auth/request-gate.md @@ -22,21 +22,20 @@ Decisions settled while planning (2026-10-09): reset once FETCH_OK is out). Racing the whole handler future and dropping it was rejected because it loses those graceful refusals. - **Rust and JS together**, so the shape and names stay mirrored. -- **Effective authorization.** Land after request-token support. Dispatch - admits a request carrying a token by its verified request grant, even when - local session permission covers it, and a request without one by local - session permission (revised on #5148); peer-issued AUTH_OK grants cannot - authorize incoming requests. Preserve - renewal, revocation, expiry and the session ceiling. Do not reject a valid - token-only request merely because it is outside the session union. Joining - FETCH follows the named subscription's live authorization and ends with - that subscription; standalone FETCH gains no request-token authority. - JS mirrors the gate structure without silently adding its deferred - request-token setter/acceptor. +- ✅ Decided 2026-10-15 (maintainer, #5148): **effective authorization.** + An unnamed request is held to the union of session-lifetime auths; a + request named by its own auth (a moq-transport request token today, + `auth=id` on lite later) is held to that auth's issued grant alone, and + ends with it. Peer-issued AUTH_OK grants never authorize incoming + requests. Preserve renewal (a new auth replacing the old), revocation, + and expiry. Joining FETCH follows the named + subscription's live authorization and ends with that subscription; + standalone FETCH gains no request-token authority. JS mirrors the gate + structure. - **Structure only.** Finding new gaps is not the goal, but any request type the refactor shows was ungated gets a regression test in the same PR. Initial verification already consumes request capacity and observes - cancellation in the request-token hook; those fixes must not wait here. + cancellation (#5148); those fixes must not wait here. Today `auth::Gate` is built inside each handler: the IETF publisher's SUBSCRIBE, standalone FETCH, and TRACK_STATUS; the IETF subscriber; and the @@ -45,13 +44,9 @@ A joining FETCH carries its subscription's namespace and observes that subscription's effective authorization. A surviving cache is not authority to continue a dependent join after the subscription ends. -Test token-only admission outside the session union, session-union changes -without revoking a still-valid request grant, request revoke/renewal/expiry, -and dependent joining FETCH. Keep graceful terminal messages. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - both admission lifetimes exist before this refactor +Test named admission outside the session union, session-union changes +without revoking a still-valid named grant, revoke/renewal/expiry of a named +auth, and dependent joining FETCH. Keep graceful terminal messages. ## Related diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md deleted file mode 100644 index ed3622f5bc..0000000000 --- a/quest/m1/auth/request-token.md +++ /dev/null @@ -1,122 +0,0 @@ -# [L] An experimental Rust hook verifies a request token - -## Goal - -Rust applications can opt into verifying a moq-transport token on SUBSCRIBE -or PUBLISH_NAMESPACE and renewing that request's grant in band. This is a -temporary interoperability hook, not a complete authentication system or -relay feature. The application supplies verification and policy. No request -token becomes session authority, and a peer's AUTH_OK cannot authorize its -incoming requests. - -The hook retains renewal, expiry, revocation, and dependent joining FETCH. -Per-request relay integration, JS setters and acceptors, CLI flags, and -bindings are deferred until a consumer needs them. Session AUTH work remains -separate. - -## Plan - -Continue [#5148](https://github.com/moq-dev/moq/pull/5148), which carries -[#4675](https://github.com/moq-dev/moq/pull/4675) forward. Decisions confirmed -2026-10-12: - -- **Scope.** Keep the Rust hook and renewal instead of reducing the work to - decoding or expanding it into relay integration. Interoperability is the - reason to expose it; carrying a token alone does not authenticate it. -- **Verifier API** (revised by the maintainer on #5148, replacing the single - event queue first chosen). Two opt-in queues: `requests()` yields session - tokens, unchanged from main, and `scoped()` yields `Scoped` request tokens - with their own acceptance handle and bound. An application verifies request - tokens only by taking `scoped()`; if nothing takes it, a token-carrying - request is refused `NOT_SUPPORTED`, so the relay needs no code to refuse - them. The types distinguish a session grant advertised to a peer from a - locally enforced request grant (`Enforced`). -- **Admission** (revised on #5148). Every request that carries a token is - verified by it, even when local session permission already covers the - request, with the accepted grant checked against that request's path and - kind. A request without a token is still admitted by local session - permission. Grants received in AUTH_OK describe what the peer allows us to do; - they never skip verification of what the peer asks us to do. Without local - permission or a verified request grant, refuse `UNAUTHORIZED`; without an - application request verifier, refuse a token requiring verification as - `Unsupported`. Malformed token encodings still fail before admission. -- **Lifetime and bounds.** Pending initial verification consumes request - capacity before awaiting the application and observes request/session - cancellation. Withdrawal must cancel pending work and make late answers - ineffective; any verifier work still in flight remains counted until it - ends. Renewal has the same ownership discipline. Reuse the bounded - pending-AUTH queue and `Request::closed` withdrawal that session tokens - already use; do not defer the initial-admission fix to the later - request-gate refactor. -- **Documentation.** Minimal Rust API comments only: experimental hook, - application verification required, no per-request relay integration, and - setter success is not peer authorization. No feature page, guide, or guide - quest. Keep required wire registries synchronized with changed codes. - -Retained decisions: - -- **Wire scope.** Honor tokens on SUBSCRIBE, unsolicited PUBLISH_NAMESPACE, - and their renewal updates; other request tokens grant no authority. The - solicited NAMESPACE response has no request-token slot. Preserve the - decoder rules landed by [#5107](https://github.com/moq-dev/moq/pull/5107), - including session-fatal malformed/forbidden forms and request-level - refusal of repeated identical tokens. SETUP retains its own alias rules. -- **Renewal across drafts.** SUBSCRIBE_UPDATE renews subscriptions on drafts - 14 and 15; REQUEST_UPDATE renews subscriptions and namespaces from 16. - Fix #5148's missing draft-16 namespace renewal, including Rust/JS decoding - and refusal of malformed, forbidden, or repeated tokens on that path. -- **Request grants stay local.** A grant covers only its request, never - joins a session union, and ends with the request. A refused renewal ends - only that request and does not retain the old grant: REQUEST_ERROR and - PUBLISH_DONE `UPDATE_FAILED` for a subscription, a closed namespace stream - from draft 17, or PUBLISH_NAMESPACE_CANCEL on draft 16. Expiry and revocation - also end only the affected request. -- **Joining FETCH.** Inherit the named subscription's live authorization, - confined to its pinned track and allowed range, observing renewal, - revocation, expiry, and the session ceiling. End with that subscription; - neither a cached namespace nor a grant snapshot prolongs authority. - Standalone FETCH gains no request-token authorization. -- **Client credential.** Keep `auth::Handle::set_request_token` and typed - `setup::Token`, encoding only `USE_VALUE`; defer aliases. It is a default - for outgoing requests on the connection, not a token chosen individually - for each request. Replacement renews live eligible requests, and success - means local configuration accepted, not authentication acknowledged. - `Connection::auth()` owns it across reconnects and - `connect::Config::with_request_token` seeds it. Extend the existing - `Connection::auth()` handle and `server::Request::auth()` passthrough; add no - parallel `Client` methods. -- **Update credit.** Advertise and enforce MAX_REQUEST_UPDATES on draft 19+, - with TOO_MANY_REQUEST_UPDATES (0x1B) in the shared Rust/JS session registry - and moq-lite's registry. Earlier drafts keep a local guard that ends only - the request. Keep one renewal in flight where updates receive answers; - draft 14 must permit successive replacements without waiting for answers. -- Typed `EXPIRED_AUTH_TOKEN` and received `MALFORMED_AUTH_TOKEN` errors remain - with [Expired token error](/quest/m1/auth/expired-error.md); preserving the - existing decoder's emitted refusal does not wait on that quest. - -Verification belongs in existing CI suites. Reproduce the audit's covering -peer AUTH_OK with a local verifier that refuses: the verifier must run and -the incoming request must fail. Cover both request kinds and the independent -local-session permission case. Exercise stalled initial verification at the -existing request cap, cancellation, late completion, and capacity release. -Preserve tests for scoped grants, renewal refusal, expiry/revocation, two -draft-14 replacements, draft-16 namespace renewal, and token-only joining -FETCH on drafts 14-19 plus native fill on newer drafts. Keep unsupported -protocols explicit. Run scoped checks, wire-draft validation when applicable, -and cross-language interop for the wire changes. - -Public API: an additive opt-in `scoped()` request-token queue beside the -unchanged `requests()`, plus the request-token setter and persistent -connection integration. Export only the types consumers need. -JS receives necessary decoder/registry parity, not a request-token API. -Wire: existing 0x03 tokens and draft-specific renewal messages, plus -MAX_REQUEST_UPDATES (0x08) and TOO_MANY_REQUEST_UPDATES (0x1B) for draft 19+; -0x1B also joins moq-lite's registry. No complete authentication scheme is -promised by these mechanisms. - -## Related - -- [#5106](https://github.com/moq-dev/moq/pull/5106) - landed setup extension - configuration; no remaining prerequisite for starting this hook -- [Request leases](/quest/m3/auth-request-lease.md) - deferred relay - verification, not a prerequisite for this experimental hook diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index 71c0688444..e7ac3b1894 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -15,10 +15,9 @@ REQUEST_UPDATE with a fresh token before it expires. Step 3 of the unified auth model settled on [#5148](https://github.com/moq-dev/moq/pull/5148) on 2026-10-15, ranked right -after step 1 because it has a consumer now. #5148 is rewriting -[Request tokens](/quest/m1/auth/request-token.md) and the auth README from -their older `scoped()` queue and `set_request_token` design to this unified -model; read it there until it lands. +after step 1 (#5148, incoming request tokens as auths on `requests()`) +because it has a consumer now. The decisions are recorded in the +[auth README](/quest/m1/auth/README.md). - ✅ Decided 2026-10-15 (maintainer): general request updates, not a per-request token setter. Each `Route`/`Subscription` carries an @@ -26,6 +25,17 @@ model; read it there until it lands. update automatically. Unset means the session's scope. - ✅ Decided 2026-10-15 (maintainer): outgoing tokens go through `add()`; #5148's `set_request_token` and its renewal sender are not carried forward. +- Renewing many live requests at once must not take one round trip per + request: each request's update is its own in-flight step, polled beside the + session's other announce and subscribe work, so new announcements and + withdrawals never wait behind a renewal. At most one update is in flight + per request, superseded by a newer auth while it waits for credit. Request + IDs reach the wire in allocation order (drafts 14-16 require the next + sequential ID), and request-ID credit bounds how many start at once, with + no new cap. Benchmark in `rs/moq-net/benches` swept over live requests and + sessions sharing an origin, so the cost grows only with the requests + renewed. (From the deleted concurrent-renewal quest, which measured this + against #5148's earlier serial refresh.) - PUBLISH_NAMESPACE renewal comes first. Per-SUBSCRIBE tokens follow the same shape but have no consumer yet; include them only if they fall out of the same code. @@ -37,17 +47,13 @@ model; read it there until it lands. - Settle the type and field names with the maintainer before implementing; mirror them in `js/net` and the bindings. - Describe request renewal in `doc/concept/moq-lite.md`'s Authorization - section, and in `doc/concept/standard.md`, which says a request's token - grants nothing; the moq-transport path can land before the lite one. + section, and in `doc/concept/standard.md`, which describes only incoming request + tokens; the moq-transport path can land before the lite one. Public API: a new optional auth on routes and subscriptions in Rust and JS. Wire: none beyond existing REQUEST_UPDATE, SUBSCRIBE_UPDATE and ANNOUNCE_UPDATE, with lite's auth field from Requests name their auth. -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - step 1: request tokens become auths on the shared acceptor queue - ## Related - [Requests name their auth](/quest/m1/auth/request-auth-id.md) - the lite path's prerequisite; moq-transport renewal doesn't wait on it diff --git a/quest/m3/README.md b/quest/m3/README.md index 54c5c99a46..c95bafe46e 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -42,7 +42,7 @@ m4's upstream waits in here and moved m2 work with no consumer. - [Conan remote](/quest/m3/cpp-conan.md) - a remote we own serves the same tarball to `conan install` - [Linux OBS GPU input](/quest/m3/obs-linux-gpu.md) - publish OBS compositor frames without CPU readback on a validated Linux graphics/encoder combination - [Routing cost domains](/quest/m3/routing-cost-domains.md) - design operator boundaries and policy without adding incomparable costs -- [Relay request tokens](/quest/m3/auth-request-lease.md) - verify a request token with its own lease when a consumer needs relay integration +- [Relay request tokens](/quest/m3/auth-request-lease.md) - the auth server sees a request token's lease end with its request - [Request tokens beyond Rust](/quest/m3/request-token-surfaces.md) - a JS, moq-ffi, or moq-cli request-token API, built only for the surface a consumer names - [Token on FETCH](/quest/m3/auth-token-fetch.md) - a request token authorizes TRACK_STATUS and FETCH; parked on a maintainer decision, with m1 recommended - [moq-transport cluster peers](/quest/m3/ietf-cluster-peers.md) - the cluster draft carries the route layer, so moq-transport relays peer again, once a moq-transport relay operator asks diff --git a/quest/m3/auth-request-lease.md b/quest/m3/auth-request-lease.md index 4a3063946f..ad7632b299 100644 --- a/quest/m3/auth-request-lease.md +++ b/quest/m3/auth-request-lease.md @@ -1,47 +1,30 @@ -# [M] moq-relay honors a request token with its own lease +# [M] moq-relay ends a request token's lease with its request ## Goal -moq-relay verifies a moq-transport request token through `moq_auth::Client`, -each request getting a lease of its own that is never counted as a session -grant and ends when the request ends. Until this lands the relay explicitly -refuses request-token verification as `Unsupported`, even if it consumes -session-token events and once [Request tokens](/quest/m1/auth/request-token.md) -merges. Every token-carrying request is verified by its token, even one the -session's own permission covers (decided on #5148). +The auth server sees a moq-transport request token's lease end when its +request ends. Since #5148, a request token reaches the relay on `requests()` +like any in-band token and is verified through the same attach path; moq-net +confines its grant to the request. The auth server still counts it as one +more attached grant on the session that never reports `end`, against its own +`--limit-token` cap until the connection ends. ## Plan -Deferred on 2026-10-12 until a consumer needs relay request verification. -This is not required for the experimental Rust hook or the session AUTH epic. -Revisit the design against moq-transport's authentication model at that point; -the lease integration below is future work, not a capability of the hook. +✅ Decided 2026-10-15 (maintainer, #5148): a request token is one kind of +auth, named by its request, so the relay keeps a single verification path and +needs no request-specific variant on `requests()`. -An application built on moq-net opts into request-token verification through -the acceptor API #5148 ships, verifies each token-bearing request itself -before accepting it, and does not need this. This quest is the relay's half of -[Request tokens](/quest/m1/auth/request-token.md). +Deferred until a consumer needs per-request accounting at the auth server. -- A per-request call on `moq_auth::Client`, not the `Client::attach` that - session tokens use. `attach` connects - with the connection's id, and the auth server treats that as one more grant - on the session that never POSTs `end`, so a request token would widen the - whole connection for its life. The per-request call carries the token in - `moq_auth::Request.token` with its kind and the request's path, is never - counted as a session grant, and ends when the request ends. Two requests - carrying the same bytes get two leases. Name the call while implementing. -- The request is resolved against the origin with the path checked against - that lease's grant, not through the session's scoped origin handle. -- Docs: `doc/bin/relay/auth.md` states the rule: a request carrying a token - is decided by its verified request grant alone, a request without one by - the session grant, and anything else is `UNAUTHORIZED`. It also says that a - request token covers only its request. -- Tests: through the relay, the auth server never counts a request token as a - session grant and sees its lease end with the request. +- The relay drops a request token's credential when its issued grant closes. + The missing piece is telling the auth server: either a per-request call on + `moq_auth::Client` that ends the lease, or an `end` for an attachment that + closes before its connection. Name the call while implementing. +- Two requests carrying the same bytes get two leases. +- Docs: `doc/bin/relay/auth.md` states that a request token's lease ends with + its request. +- Tests: through the relay, the auth server sees a request token's lease end + with the request, while the connection's own lease continues. Public API: additive on `moq_auth::Client`. Wire: none. - -## Required - -- [Request tokens](/quest/m1/auth/request-token.md) - the per-request grant - this lease backs diff --git a/quest/m3/auth-token-fetch.md b/quest/m3/auth-token-fetch.md index fc459440bb..16208c9303 100644 --- a/quest/m3/auth-token-fetch.md +++ b/quest/m3/auth-token-fetch.md @@ -11,12 +11,10 @@ with it, under the same rules #5148 set for SUBSCRIBE. ## Plan -A #5148 follow-up. Today the token rides only SUBSCRIBE and +A #5148 follow-up. Today a token authorizes only SUBSCRIBE and PUBLISH_NAMESPACE. Fetch-only demand (TRACK_STATUS, then FETCH) and -cache-miss group FETCHes still need the connection grant. So a token-only +cache-miss group FETCHes still need the session scope. So a token-only peer can subscribe to a track but cannot look it up or fetch its history. -#5148's `a_request_token_never_authorizes_a_track_status` pins that -behavior, and this quest reverses it. Open question for the maintainer. This reverses a settled decision: "standalone FETCH gains no request-token authority" (2026-10-12, in @@ -35,12 +33,13 @@ Open question for the maintainer. This reverses a settled decision: It is parked in m3 until the maintainer answers, so an undecided scope does not gate the breaking release (from this plan's review). -Keep the hook's boundaries. Every token is verified, at most one per -request, and a peer's AUTH_OK is a ceiling, never authority. Request tokens -keep their own bound. The relay still refuses a token-carrying request -`NOT_SUPPORTED` until [Relay request tokens](/quest/m3/auth-request-lease.md). -A joining FETCH keeps following its subscription's authorization and carries -no token of its own. +Keep #5148's boundaries (✅ Decided 2026-10-15, maintainer): a token on a +request is one kind of auth on `requests()`, the request is held to its +grant alone and ends with it, every token is verified, at most one per +request, and a peer's AUTH_OK is a ceiling, never authority. Outgoing +tokens wait for `add()` with automatic naming, so the outgoing half here +lands with or after that. A joining FETCH keeps following its subscription's +authorization and carries no token of its own. Tests: incoming TRACK_STATUS and FETCH admitted by a verified token outside the session grant, refused when the verifier refuses, and ended when the diff --git a/quest/m3/request-token-surfaces.md b/quest/m3/request-token-surfaces.md index 5c6401bcb4..a5cc15889e 100644 --- a/quest/m3/request-token-surfaces.md +++ b/quest/m3/request-token-surfaces.md @@ -4,28 +4,27 @@ When a consumer needs it, a JS, binding, or CLI application can present a request token on its outgoing requests and, where the consumer verifies -tokens, accept them. Each surface mirrors the Rust names from #5148. Only the -surfaces a named consumer uses ship. +tokens, accept them. Each surface mirrors the Rust names. Only the surfaces a +named consumer uses ship. ## Plan -Deferred from #5148 and the [in-band auth](/quest/m1/auth/README.md) -per-request boundary (2026-10-12). The request-token hook is an experimental -Rust interoperability hook. The JS setter and accept-side queue, a -`moq --request-token` flag, and a moq-ffi setter wait for a consumer. Start -by asking that consumer which surface it needs. Do not build all three. +✅ Decided 2026-10-15 (maintainer, #5148): a request token is one kind of +auth. Incoming ones already arrive on the session's `requests()` queue, and +outgoing ones come later through `add()` with automatic naming, sent as +`USE_VALUE` on plain moq-transport. There is no separate request-token setter +or acceptor to mirror. -Mirror the Rust API that #5148 ships, its names and its opt-in verification -included, rather than designing a new shape per surface. Through moq-ffi, follow the session AUTH shape that -[Bindings](/quest/m1/auth/bindings.md) settles. Update every wrapper, as the -cross-package table requires. A CLI flag keeps the token out of logs and -`Debug`, like Rust's. moq-lite cannot carry a token. Fail loud there, as -Rust's `Unsupported` does. +So each surface here exposes that unified shape once Rust has it, and only +for the surface a named consumer uses. Start by asking that consumer which +surface it needs. Do not build all three. Through moq-ffi, follow the session +AUTH shape that [Bindings](/quest/m1/auth/bindings.md) settles. Update every +wrapper, as the cross-package table requires. -Public API: new request-token setters, and possibly an acceptor, on the -chosen surfaces. Wire: none. +Public API: whatever the chosen surface needs to present and accept auths +per request. Wire: none. ## Related -- [#5148](https://github.com/moq-dev/moq/pull/5148) - the Rust hook these surfaces expose +- [#5148](https://github.com/moq-dev/moq/pull/5148) - incoming request tokens in Rust - [Relay request tokens](/quest/m3/auth-request-lease.md) - the relay's half, also waiting on a consumer diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 08368918cb..207632e0b8 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -14,6 +14,12 @@ //! [`authorize`](Handle::authorize) re-authorizes the peer on a live session, narrower //! or wider, on every version, whether or not it speaks AUTH. //! +//! Every token the peer presents is one kind of [`Request`], answered on the same queue, +//! wherever it rode: its own AUTH stream, or a moq-transport `AUTHORIZATION TOKEN` on a +//! SUBSCRIBE or PUBLISH_NAMESPACE. The session enforces each grant it issues. A request +//! that carried a token is held to that token's grant alone and ends with it; any other +//! request is held to the session's scope, which a token carried by a request never joins. +//! //! 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` fails with @@ -130,11 +136,19 @@ pub(crate) struct State { union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// The most this side lets the peer do, in the peer's terms: `None` (only the - /// origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// What the peer's connection credential lets it do, in the peer's terms: `None` (only + /// the origin handles bound it) until [`Handle::authorize`] sets it; the grant it was /// last called with. limit: Option, - /// Bumped whenever the union or the limit changes, so the session's per-stream + /// The grants the application issued to the peer's AUTH tokens, by stream, in the + /// peer's terms. A token on a single request never joins them. + issued: BTreeMap, + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_issue: u64, + /// The session's scope for the peer: the limit and every issued grant, `None` while the + /// limit is. + scope: Option, + /// Bumped whenever the union or the scope changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, @@ -188,25 +202,40 @@ impl State { } } + /// Re-derive the scope from the limit and the issued grants. + fn rescope(&mut self) { + let scope = self.limit.as_ref().map(|limit| { + let mut scope = limit.clone(); + for grant in self.issued.values() { + scope.union(grant); + } + scope + }); + if self.scope != scope { + self.scope = scope; + self.epoch += 1; + } + } + /// The error handed to anything waiting on a session that has ended. fn closed(&self) -> Option { self.closed.clone() } - /// The union's and the limit's patterns for `direction`, each `None` while + /// The union's and the scope's patterns for `direction`, each `None` while /// unrestricted. #[cfg_attr(not(feature = "async"), allow(dead_code))] fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { - // The peer's grant names what we may do; the limit names what the peer may, + // The peer's grant names what we may do; the scope names what the peer may, // so what we send is what the peer may subscribe to, and the other way around. match direction { Direction::Publish => ( self.union.as_ref().map(|union| &union.publish), - self.limit.as_ref().map(|limit| &limit.subscribe), + self.scope.as_ref().map(|scope| &scope.subscribe), ), Direction::Subscribe => ( self.union.as_ref().map(|union| &union.subscribe), - self.limit.as_ref().map(|limit| &limit.publish), + self.scope.as_ref().map(|scope| &scope.publish), ), } } @@ -243,7 +272,12 @@ impl Permit { /// Whether both allow `path`. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn matches(&self, path: &str) -> bool { - self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_limit(path) + self.granted(path) && self.within_limit(path) + } + + /// Whether the peer's grant alone allows `path`. + pub(crate) fn granted(&self, path: &str) -> bool { + self.granted.as_ref().is_none_or(|granted| granted.matches(path)) } /// Whether the limit alone allows `path`: for what the peer offers us, which our @@ -346,17 +380,19 @@ impl Handle { }) } - /// Answer every token the peer presents, instead of the default acceptor. + /// Answer every token the peer presents, instead of the default acceptor: on its own + /// AUTH stream, or riding one of its moq-transport SUBSCRIBE or PUBLISH_NAMESPACE + /// requests as an `AUTHORIZATION TOKEN`. /// /// Call it before running the session's driver: whoever answers is decided /// once, on the driver's first poll, so a later call cannot take over tokens /// already in flight. Without it the session grants the peer what its own /// origin handles allow for the connection's credential, and refuses any other - /// token as unsupported. + /// token as unsupported, along with the request that carried it. /// - /// At most 128 unanswered tokens queue, not counting withdrawn ones; more are - /// refused with [`SessionError::TooManyRequests`] (`UNAUTHORIZED` on moq-transport, - /// whose AUTH_ERROR registry has no such code). + /// The peer's credit bounds how many tokens wait unanswered: each AUTH token holds its + /// stream open, and each request token holds its request's slot, until it is answered or + /// withdrawn. A withdrawn token leaves the queue. /// /// # Errors /// @@ -368,15 +404,40 @@ impl Handle { return Err(Error::Duplicate); } let queue = Pending::default(); - // A version without AUTH never receives a token, so its requests end with - // the session like any other. - if !state.supported { + if state.closed.is_some() { queue.close(); } state.acceptor = Acceptor::App(queue.clone()); Ok(Requests { queue }) } + /// Ask the acceptor to verify a token that rode on one of the peer's requests. + /// + /// The token reaches it through [`requests`](Self::requests) like any other. The grant it + /// answers covers only this request, never joins the session's scope, and ends when the + /// returned [`RequestVerdict`] is dropped. Without an application acceptor, the verdict is + /// [`Error::Unsupported`]. So it is for a token type other than + /// [`Token::OUT_OF_BAND`](crate::setup::Token::OUT_OF_BAND), which an AUTH token cannot name, + /// and for an empty token, which on AUTH means the connection's own credential and so would + /// reach the acceptor as that credential. `hold` is the request's slot, kept while the + /// application verifies the token. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn verify_request(&self, token: crate::setup::Token, hold: Option) -> RequestVerdict { + let Some(queue) = self.acceptor() else { + return RequestVerdict { issue: None }; + }; + if token.kind != crate::setup::Token::OUT_OF_BAND || token.value.is_empty() { + return RequestVerdict { issue: None }; + } + let issue = kio::Shared::new(Issue { + hold, + ..Default::default() + }); + // A closed or full queue refuses the token. + queue.push(Request::new(token.value.into(), issue.clone())); + RequestVerdict { issue: Some(issue) } + } + /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -489,29 +550,48 @@ impl Handle { Poll::Ready(()) } - /// Authorize the peer for `grant` on this session, in the session's own paths. - /// Calling it again re-authorizes, narrower or wider, replacing the last grant. The - /// session's origin handles still bound it, so it never grants more than they allow. + /// Authorize the peer's connection credential for `grant` on this session, in the + /// session's own paths. Calling it again re-authorizes, narrower or wider, replacing the + /// last grant. The session's origin handles still bound it, so it never grants more than + /// they allow. /// - /// A narrower grant ends what falls outside at once, with [`Error::Unauthorized`] + /// The session's scope for the peer is this grant and every grant issued to one of its + /// AUTH tokens through [`requests`](Self::requests); a session never authorized is bound + /// only by its origin handles. A request that carries its own token is decided by that + /// token instead, inside the scope or not. + /// + /// A narrower scope ends what falls outside at once, with [`Error::Unauthorized`] /// where it has a reader: announcements to the peer retract, its new requests are /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old grant held back: announcements to the peer are made + /// one brings back what the old scope held back: announcements to the peer are made /// again, and so are the peer's announcements it withheld from the origin, and new /// requests are accepted. The rest of the session carries on. It works on every /// version, since the session enforces it without the peer's help. /// /// When the session answers the peer's connection credential itself, the peer is - /// told the grant, within what the origin handles allow, `expires` included. An application answering - /// tokens through [`requests`](Self::requests) updates its own [`Issued`] grants. + /// told the grant, within what the origin handles allow, `expires` included. An + /// application answering tokens through [`requests`](Self::requests) updates its own + /// [`Issued`] grants. pub fn authorize(&self, grant: &Grant) { let mut state = self.state.lock(); if state.limit.as_ref() != Some(grant) { state.limit = Some(grant.clone()); state.epoch += 1; + state.rescope(); } } + /// Record the grant an application issued to the peer's AUTH token `id`, or forget it with + /// `None`, resizing the scope. + fn issue(&self, id: u64, grant: Option) { + let mut state = self.state.lock(); + match grant { + Some(grant) => state.issued.insert(id, grant), + None => state.issued.remove(&id), + }; + state.rescope(); + } + /// The union once it changed since `epoch` last saw it, advancing `epoch`. It /// only ever changes into `Some`: no answer yet is the initial state. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -554,17 +634,40 @@ impl Handle { self.state.read().allows(direction, path) } - /// Whether the limit alone allows `direction` at `path`: for a route the peer + /// Whether a request token decides the request carrying it, rather than the session's + /// scope: the application verifies every token, or the request is refused as unsupported + /// without an acceptor, even where the scope already covers it. A token admitted unread + /// would leave the peer believing it backs a request this side holds to its scope. A side + /// that neither scopes the peer nor answers tokens has nothing to check one against, and + /// admits as it would without it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn token_decides(&self, direction: Direction) -> bool { + let state = self.state.read(); + let (_, scope) = state.parts(direction); + scope.is_some() || matches!(state.acceptor, Acceptor::App(_)) + } + + /// Whether the peer's grant alone allows `direction` at `path`, allowing everything + /// while it is `None`: what bounds this side whatever authorized the request. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn within_grant(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + let (granted, _) = state.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) + } + + /// Whether the scope alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { let state = self.state.read(); - let (_, limit) = state.parts(direction); - limit.is_none_or(|limit| limit.matches(path)) + let (_, scope) = state.parts(direction); + scope.is_none_or(|scope| scope.matches(path)) } - /// The peer turned out not to negotiate AUTH: fail every token as unsupported and - /// close the requests, leaving the union unknown. + /// The peer turned out not to negotiate AUTH: fail every token we presented as + /// unsupported, leaving the union unknown. The peer's tokens can still ride its requests, + /// so their queue stays open. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn unsupported(&self) { let mut state = self.state.lock(); @@ -576,9 +679,6 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } } /// End the session: fail every pending token, end every watch, and close the @@ -597,7 +697,13 @@ impl Handle { slot.ended.get_or_insert(err.clone()); } state.recompute(); - if let Acceptor::App(queue) = &state.acceptor { + let queue = match &state.acceptor { + Acceptor::App(queue) => Some(queue.clone()), + _ => None, + }; + // Refusing the queued tokens takes this lock again, to drop them from the scope. + drop(state); + if let Some(queue) = queue { queue.close(); } } @@ -779,7 +885,7 @@ impl Drop for Requests { } } -/// Pending application decisions, bounded independently of transport stream credit. +/// Pending application decisions, bounded by the stream and request credit each one holds. #[derive(Clone, Default)] pub(crate) struct Pending { state: kio::Shared, @@ -788,6 +894,9 @@ pub(crate) struct Pending { #[derive(Default)] struct PendingState { requests: VecDeque, + /// How many requests survived the last prune: pruning again only once the queue doubles + /// keeps a push amortized O(1). + survivors: usize, closed: bool, } @@ -798,7 +907,7 @@ impl Pending { state.requests.clear(); } - /// Queue the token for the application, or refuse it when closed or full. + /// Queue the token for the application, or refuse it when closed. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn push(&self, request: Request) { let mut state = self.state.lock(); @@ -806,14 +915,11 @@ impl Pending { if state.closed { return; } - // A withdrawn stream must not occupy a slot while the verifier is busy. - if state.requests.len() >= 128 { - state.requests.retain(|request| !request.ended()); - } - if state.requests.len() >= 128 { - drop(state); - request.reject(SessionError::TooManyRequests, "too many pending tokens"); - return; + // A withdrawn token gave its credit back, so it must not stay queued while the + // acceptor is busy: that would let a peer that keeps withdrawing grow the queue. + if state.requests.len() >= 2 * state.survivors.max(1) { + state.requests.retain(|request| request.issue().read().peer.is_none()); + state.survivors = state.requests.len(); } state.requests.push_back(request); } @@ -828,7 +934,12 @@ impl Pending { } })); while let Some(request) = state.requests.pop_front() { - if !request.ended() { + // Taken under the same lock its withdrawal checks, so from here the application + // owns the request's slot until it answers. + let mut issue = request.issue().lock(); + if issue.peer.is_none() { + issue.taken = true; + drop(issue); return Poll::Ready(Some(request)); } } @@ -856,8 +967,42 @@ pub(crate) struct Issue { pub done: bool, /// Why the stream ended on the peer's side, once it has. pub peer: Option, + /// A request token's share of its request's slot, held while the application verifies it. + hold: Option, + /// The application took the token off the queue. + taken: bool, + /// An AUTH token's place in the session's scope, which its grant joins. A token that rode + /// on a request has none: its grant covers only that request. + scoped: Option<(Handle, u64)>, } +impl Issue { + /// Push a reply, keeping the session's scope in step with the grant an AUTH token holds. + /// Called under the issue's lock, so a withdrawal recorded under it is never undone. + fn reply(&mut self, reply: Reply) { + if let Some((handle, id)) = &self.scoped { + match &reply { + Reply::Grant(grant) if self.peer.is_none() => handle.issue(*id, Some(grant.clone())), + _ => handle.issue(*id, None), + } + } + self.outbox.push_back(reply); + } + + /// The application is done with the token: its grant leaves the scope. + fn finish(&mut self) { + self.done = true; + if let Some((handle, id)) = &self.scoped { + handle.issue(*id, None); + } + } +} + +/// A request's slot against the session's cap, shared with the application's verification +/// of its token so work still in flight stays counted after the request ends. Opaque here: +/// auth only keeps it alive. +pub(crate) type Hold = std::sync::Arc; + /// The serving task's hold on one peer token's [`Issue`]. /// /// Dropping it settles [`Issued::closed`] with the session's error, unless the task @@ -872,16 +1017,29 @@ impl Serving { /// A fresh stream's shared state, settled by the session `handle` if the task drops. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn new(handle: Handle) -> Self { + let id = { + let mut state = handle.state.lock(); + state.next_issue += 1; + state.next_issue + }; Self { - issue: kio::Shared::default(), + issue: kio::Shared::new(Issue { + scoped: Some((handle.clone(), id)), + ..Default::default() + }), handle, } } - /// Record why the stream ended on the peer's side; the first reason wins. + /// Record why the stream ended on the peer's side; the first reason wins. Whatever it + /// granted leaves the scope. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn end(&self, err: Error) { - self.issue.lock().peer.get_or_insert(err); + let mut issue = self.issue.lock(); + issue.peer.get_or_insert(err); + if let Some((handle, id)) = &issue.scoped { + handle.issue(*id, None); + } } } @@ -894,6 +1052,10 @@ impl Drop for Serving { /// A token the peer presented, waiting for an answer. /// +/// It arrived on its own AUTH stream, or on one of the peer's moq-transport requests. Either +/// way the grant it earns is enforced by the session: an AUTH token's joins the session's +/// scope, while one that rode on a request admits only that request, which ends with it. +/// /// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. pub struct Request { token: Bytes, @@ -909,31 +1071,34 @@ impl Request { } } + fn issue(&self) -> &kio::Shared { + self.issue.as_ref().expect("unanswered request") + } + /// The token the peer presented. Empty means the credential its connection /// already carried, or none. pub fn token(&self) -> &Bytes { &self.token } - fn ended(&self) -> bool { - self.issue.as_ref().expect("unanswered request").read().peer.is_some() - } - - /// Wait for the peer to withdraw this unanswered token or close the session. + /// Wait for the peer to withdraw this unanswered token, end the request it rode on, or + /// close the session. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - closed(self.issue.as_ref().expect("unanswered request")).await + closed(self.issue()).await } /// Grant the token. The grant holds until the returned [`Issued`] is revoked - /// or dropped, or the peer withdraws the token. + /// or dropped, or the peer withdraws the token or ends the request it rode on. /// - /// The session only tells the peer: the origin handles this side serves and - /// accepts with are what enforce the grant, and revoking it once `expires` - /// passes is the acceptor's job. + /// Revoking an AUTH token's grant once `expires` passes is the acceptor's job; a grant + /// on a request lapses at `expires` by itself. pub fn accept(mut self, grant: Grant) -> Issued { let issue = self.issue.take().expect("answered once"); - issue.lock().outbox.push_back(Reply::Grant(grant)); + let mut locked = issue.lock(); + locked.reply(Reply::Grant(grant)); + locked.hold = None; + drop(locked); Issued { issue } } @@ -941,6 +1106,7 @@ impl Request { pub fn reject(mut self, code: SessionError, reason: impl Into) { let issue = self.issue.take().expect("answered once"); refuse(&issue, code, reason.into()); + issue.lock().hold = None; } } @@ -948,6 +1114,7 @@ impl Drop for Request { fn drop(&mut self) { if let Some(issue) = self.issue.take() { refuse(&issue, SessionError::Unauthorized, "unanswered".to_string()); + issue.lock().hold = None; } } } @@ -955,22 +1122,340 @@ impl Drop for Request { fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { let mut issue = issue.lock(); if !issue.done { - issue.outbox.push_back(Reply::Refuse { code, reason }); - issue.done = true; + issue.reply(Reply::Refuse { code, reason }); + issue.finish(); + } +} + +/// Which request a token rode on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) enum RequestKind { + /// A SUBSCRIBE: the peer reads the named track. + Subscribe, + /// A PUBLISH_NAMESPACE: the peer announces a namespace it will publish under. + PublishNamespace, +} + +impl RequestKind { + /// Whether `grant`, issued to the peer that presented the token, covers this request at + /// `path`. A grant names what its holder may do, so a request to read is covered by its + /// `subscribe` patterns and a request to announce by its `publish` patterns. An announce + /// routes everything beneath its namespace, so the grant must cover that whole subtree. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe => grant.subscribe.matches(path), + Self::PublishNamespace => { + crate::Pattern::subtree(path).is_ok_and(|subtree| grant.publish.contains(&subtree)) + } + } + } +} + +/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// +/// Holding it keeps the request's grant alive; dropping it tells the acceptor the request +/// is over, so the grant lives exactly as long as the request and never outlives it. +pub(crate) struct RequestVerdict { + issue: Option>, +} + +/// Why a request token did not authorize its request: the error whose code the peer is +/// sent, and the reason phrase with it, which the acceptor chose when it refused. +#[derive(Debug)] +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) struct Refusal { + pub error: Error, + pub reason: String, +} + +impl Refusal { + fn new(error: Error, reason: &str) -> Self { + Self { + error, + reason: reason.to_string(), + } + } +} + +impl From for Refusal { + fn from(error: Error) -> Self { + let reason = error.to_string(); + Self { error, reason } + } +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl RequestVerdict { + /// Wait for the acceptor's first answer. See [`poll_grant`](Self::poll_grant). + #[cfg(test)] + pub(crate) async fn grant(&self) -> std::result::Result { + kio::wait(|waiter| self.poll_grant(waiter)).await + } + + /// Poll for the acceptor's first answer: the grant it issued, or its refusal, which the + /// caller sends the peer. No consumer is [`Error::Unsupported`]; an unanswered (dropped) + /// request is [`SessionError::Unauthorized`]. Whatever the acceptor did since accepting + /// is folded in, so a grant already updated, revoked or dropped is never admitted as it + /// first stood. + pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { + let Some(issue) = &self.issue else { + return Poll::Ready(Err(Error::Unsupported.into())); + }; + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); + let mut grant = None; + while let Some(reply) = guard.outbox.pop_front() { + match reply { + Reply::Grant(next) => grant = Some(next), + Reply::Refuse { code, reason } => return Poll::Ready(Err(Refusal::new(Error::Session(code), &reason))), + } + } + Poll::Ready(match (grant, guard.done) { + (Some(grant), false) => Ok(grant), + (Some(_), true) => Err(Refusal::new( + Error::Session(SessionError::Unauthorized), + "grant dropped", + )), + // Done with nothing written: the acceptor dropped the request unanswered. + (None, _) => Err(Error::Session(SessionError::Unauthorized).into()), + }) + } + + /// After the first grant, poll for the acceptor's next action on this token: a + /// replacement grant (an update, such as a lowered expiry), a refusal (revoke), or the + /// enforced grant being dropped. `Pending` while the grant still stands. + fn poll_reply(&self, waiter: &kio::Waiter) -> Poll { + let Some(issue) = &self.issue else { + return Poll::Ready(Reply::Refuse { + code: SessionError::Unauthorized, + reason: "no verifier".to_string(), + }); + }; + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); + Poll::Ready(match guard.outbox.pop_front() { + Some(reply) => reply, + // Done with nothing more to read: the acceptor dropped the enforced grant, ending + // the request. + None => Reply::Refuse { + code: SessionError::Unauthorized, + reason: "grant dropped".to_string(), + }, + }) + } +} + +impl Drop for RequestVerdict { + fn drop(&mut self) { + if let Some(issue) = &self.issue { + let mut issue = issue.lock(); + // The request is over: settle the acceptor's `closed`, and make a late answer land + // nowhere. A token still queued gives its slot back now; one the application took + // keeps it until the application answers. + issue.peer.get_or_insert(Error::Cancel); + if !issue.taken { + issue.hold = None; + } + } } } -/// A grant issued to one of the peer's tokens. Dropping it ends the grant. +/// A request-borne grant, held for the life of the request it authorized (a SUBSCRIBE or a +/// PUBLISH_NAMESPACE). It carries the acceptor's grant and a deadline armed at the grant's +/// expiry, and ends the request when the deadline lapses or the acceptor revokes or drops the +/// grant. A REQUEST_UPDATE the acceptor accepts [`renew`](Self::renew)s it with a fresh grant +/// and expiry; one it refuses is not applied here, and the serve loop ends the request. +/// +/// Clones share the grant: the request's [`until`] watches it end while the request renews it. +/// Ending a request grant never touches the session: only that one request ends. +#[derive(Clone)] +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) struct RequestGrant(std::sync::Arc>); + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +struct Held { + verdict: RequestVerdict, + grant: Grant, + deadline: crate::time::Deadline, + /// The request this grant must keep covering: a replacement that no longer does ends it. + path: crate::PathOwned, + kind: RequestKind, + /// The request's slot, shared with a renewal's verification. + hold: Option, + clock: crate::time::Clock, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl RequestGrant { + /// Hold the acceptor's first answer `res` for the request's life, armed to lapse at its + /// expiry, or the refusal to answer the request with: the acceptor's own, or + /// [`Error::Unauthorized`] for a grant that does not cover it or has already expired. + pub(crate) fn new( + clock: &crate::time::Clock, + verdict: RequestVerdict, + res: std::result::Result, + path: crate::PathOwned, + kind: RequestKind, + hold: Option, + ) -> std::result::Result { + let grant = res?; + if !live(clock, kind, path.as_str(), &grant) { + return Err(Refusal::new(Error::Unauthorized, "request token not granted")); + } + let mut deadline = crate::time::Deadline::new(clock); + deadline.set(grant.expires); + Ok(Self(std::sync::Arc::new(std::sync::Mutex::new(Held { + verdict, + grant, + deadline, + path, + kind, + hold, + clock: clock.clone(), + })))) + } + + fn held(&self) -> std::sync::MutexGuard<'_, Held> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The grant in force right now. Observed by the lifecycle tests. + #[cfg(test)] + pub(crate) fn grant(&self) -> Grant { + self.held().grant.clone() + } + + /// The request's slot, for re-attaching what it authorized without taking another. + pub(crate) fn hold(&self) -> Option { + self.held().hold.clone() + } + + /// Start verifying a renewal's token, holding the request's slot while it runs. + pub(crate) fn verify_renewal(&self, handle: &Handle, token: crate::setup::Token) -> RequestVerdict { + let held = self.held(); + handle.verify_request(token, held.hold.clone()) + } + + /// Apply a renewal's verdict, or return the refusal that ends the request. + /// + /// A live grant that still covers the request replaces this one: the new verdict is + /// adopted (dropping the old, which ends the old token) and the deadline re-armed at the + /// new expiry. A refusal, or a grant that no longer covers the request or has already + /// expired, changes nothing here; the caller ends only that request. + pub(crate) fn renew( + &self, + verdict: RequestVerdict, + res: std::result::Result, + ) -> std::result::Result<(), Refusal> { + let grant = res?; + let mut held = self.held(); + if !live(&held.clock, held.kind, held.path.as_str(), &grant) { + return Err(Refusal::new(Error::Unauthorized, "renewal not granted")); + } + held.verdict = verdict; + held.deadline.set(grant.expires); + held.grant = grant; + Ok(()) + } + + /// Resolve with the refusal that ends the request: the deadline lapsing + /// ([`Error::Unauthorized`]), the acceptor revoking or dropping the grant (its code and + /// reason), or an acceptor-side update that no longer covers the request + /// ([`Error::Unauthorized`]). A covering update, such as a lowered expiry, is folded in and + /// polling continues. Never resolves while the grant still stands. + pub(crate) fn poll_ended(&self, waiter: &kio::Waiter) -> Poll { + let mut held = self.held(); + loop { + if held.deadline.poll(waiter).is_ready() { + return Poll::Ready(Refusal::new(Error::Unauthorized, "request token expired")); + } + match held.verdict.poll_reply(waiter) { + Poll::Ready(Reply::Grant(grant)) => { + if !held.kind.covers(&grant, held.path.as_str()) { + return Poll::Ready(Refusal::new(Error::Unauthorized, "request token no longer granted")); + } + held.deadline.set(grant.expires); + held.grant = grant; + // Re-poll: the new deadline may already have lapsed, or another reply may + // be waiting. + continue; + } + Poll::Ready(Reply::Refuse { code, reason }) => { + return Poll::Ready(Refusal::new(Error::Session(code), &reason)); + } + Poll::Pending => return Poll::Pending, + } + } + } + + /// Run `body`, everything a request does once admitted, until it finishes or `grant` + /// (when its token admitted it) ends. See [`until`]. + pub(crate) async fn guard( + grant: Option<&Self>, + body: F, + ) -> std::result::Result { + until(body, |waiter| match grant { + Some(grant) => grant.poll_ended(waiter), + None => Poll::Pending, + }) + .await + } +} + +/// Run `body` until it finishes, or until `ended` resolves first, which drops `body` wherever +/// it waits. +/// +/// An admitted request runs everything it does under this, its setup included, so authority +/// ending at any await ends the request there, rather than only where a loop remembers to +/// check. `ended` is polled first, so a request never acts on authority that already ended. +/// A dropped `body` releases what it holds; anything it was writing stays buffered whole in +/// the stream's [`Writer`](crate::coding::Writer) as long as each message is buffered before +/// its first await, so the caller can still end the request on the wire. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) async fn until( + body: F, + mut ended: impl FnMut(&kio::Waiter) -> Poll, +) -> std::result::Result { + let mut body = std::pin::pin!(body); + kio::wait(|waiter| { + if let Poll::Ready(refusal) = ended(waiter) { + return Poll::Ready(Err(refusal)); + } + waiter.poll_future(body.as_mut()).map(Ok) + }) + .await +} + +/// Whether `grant` covers a `kind` request at `path` and has not already expired: admitting +/// an expired one until its deadline fires would let others act on it meanwhile. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +fn live(clock: &crate::time::Clock, kind: RequestKind, path: &str, grant: &Grant) -> bool { + kind.covers(grant, path) && grant.expires.is_none_or(|expires| expires > clock.now()) +} + +/// A grant issued to one of the peer's tokens, which the session enforces. Dropping it +/// ends the grant: an AUTH token's leaves the scope, and a request's token ends the request. pub struct Issued { issue: kio::Shared, } impl Issued { - /// Replace the grant, such as with a lowered expiry. + /// Replace the grant, such as with a lowered expiry. A request's token whose new grant + /// no longer covers the request ends it. pub fn update(&self, grant: Grant) { let mut issue = self.issue.lock(); if !issue.done { - issue.outbox.push_back(Reply::Grant(grant)); + issue.reply(Reply::Grant(grant)); } } @@ -979,14 +1464,20 @@ impl Issued { refuse(&self.issue, code, reason.into()); } - /// Wait for the peer to withdraw the token ([`Error::Cancel`]), or for its - /// stream or the session to end. + /// Wait for the peer to withdraw the token ([`Error::Cancel`]), the request it rode on + /// to end ([`Error::Cancel`]), or its stream or the session to end. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { closed(&self.issue).await } } +impl Drop for Issued { + fn drop(&mut self) { + self.issue.lock().finish(); + } +} + #[cfg(feature = "async")] async fn closed(issue: &kio::Shared) -> Error { kio::wait(|waiter| { @@ -1002,12 +1493,6 @@ async fn closed(issue: &kio::Shared) -> Error { .await } -impl Drop for Issued { - fn drop(&mut self) { - self.issue.lock().done = true; - } -} - /// Which way media flows, from this side's view. #[derive(Clone, Copy)] #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -1030,30 +1515,92 @@ pub(crate) struct Gate { path: crate::PathOwned, direction: Direction, epoch: u64, + part: Part, + /// Whether the scope allowed the path at the last permit. + within_limit: bool, +} + +/// Which part of a [`Permit`] a [`Gate`] watches. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +enum Part { + /// The peer's grant and the scope both. + Both, + /// The peer's grant alone: a token the peer presented stands in for the scope. + Granted, + /// The peer's grant, and the scope once the announce that stood in for it closes. + Announced(kio::Consumer<()>), } impl Gate { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + let within_limit = handle.within_limit(direction, path.as_str()); Self { handle, path, direction, epoch: 0, + part: Part::Both, + within_limit, } } + /// A gate on the peer's grant alone, for a peer's request its own token authorized. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn granted(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + part: Part::Granted, + ..Self::new(handle, path, direction) + } + } + + /// A gate for a request under a namespace the peer announced, while `live` stays open: + /// the announce stands in for the scope, as its own token may have authorized it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn announced( + handle: Handle, + path: crate::PathOwned, + direction: Direction, + live: kio::Consumer<()>, + ) -> Self { + Self { + part: Part::Announced(live), + ..Self::new(handle, path, direction) + } + } + + /// Whether the session allows the path right now. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn allows(&self) -> bool { + let path = self.path.as_str(); + self.handle.within_grant(self.direction, path) + && match &self.part { + Part::Both => self.within_limit, + Part::Granted => true, + Part::Announced(live) => self.within_limit || !live.is_closed(), + } + } + /// Resolve once the session no longer allows the path. A union that is still /// `None` (no answer yet, or a version without AUTH) and a session never limited /// allow everything. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { - loop { - let permit = ready_or!(self.handle.poll_permit(self.direction, &mut self.epoch, waiter)); - if !permit.matches(self.path.as_str()) { + while let Poll::Ready(permit) = self.handle.poll_permit(self.direction, &mut self.epoch, waiter) { + let path = self.path.as_str(); + self.within_limit = permit.within_limit(path); + let allowed = match self.part { + Part::Both => permit.matches(path), + Part::Granted | Part::Announced(_) => permit.granted(path), + }; + if !allowed { return Poll::Ready(()); } } + match &self.part { + Part::Announced(live) if !self.within_limit => live.poll_closed(waiter), + _ => Poll::Pending, + } } } @@ -1174,38 +1721,49 @@ impl Enforce { mod tests { use super::*; + /// A queued request token keeps its request's slot, so the peer's request credit bounds + /// how many wait; answering it gives the slot back. #[test] - fn pending_tokens_are_bounded() { + fn a_queued_request_token_holds_its_slot() { let handle = Handle::new(true); - let _requests = handle.requests().unwrap(); - let queue = handle.acceptor().unwrap(); - for _ in 0..128 { - queue.push(Request::new(Bytes::new(), Default::default())); - } - let overflow = kio::Shared::::default(); - queue.push(Request::new(Bytes::new(), overflow.clone())); - let overflow = overflow.lock(); - assert!(overflow.done); - assert!(matches!( - overflow.outbox.front(), - Some(Reply::Refuse { - code: SessionError::TooManyRequests, - .. - }) - )); + let mut requests = handle.requests().unwrap(); + let slot: Hold = std::sync::Arc::new(()); + let token = crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }; + let _verdict = handle.verify_request(token, Some(slot.clone())); + assert_eq!( + std::sync::Arc::strong_count(&slot), + 2, + "queued, the token holds the slot" + ); + let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { + panic!("request token missing") + }; + assert_eq!( + std::sync::Arc::strong_count(&slot), + 2, + "taken, the token still holds the slot" + ); + let _issued = request.accept(Grant::all()); + assert_eq!(std::sync::Arc::strong_count(&slot), 1, "answered, the slot is released"); } + /// Withdrawn tokens leave the queue as it grows, so a peer that keeps presenting and + /// withdrawing cannot grow it past what its credit holds open. #[test] - fn withdrawn_tokens_do_not_occupy_pending_slots() { + fn withdrawn_tokens_leave_the_queue() { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let queue = handle.acceptor().unwrap(); - let expired = kio::Shared::::default(); - for _ in 0..128 { - queue.push(Request::new(Bytes::from_static(b"withdrawn"), expired.clone())); - } - expired.lock().peer = Some(Error::Cancel); queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); + let withdrawn = kio::Shared::::default(); + withdrawn.lock().peer = Some(Error::Cancel); + for _ in 0..1000 { + queue.push(Request::new(Bytes::from_static(b"withdrawn"), withdrawn.clone())); + assert!(queue.state.read().requests.len() <= 3, "withdrawn tokens piled up"); + } let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; @@ -1290,3 +1848,571 @@ mod tests { assert!(default.poll(&waiter).is_pending()); } } + +#[cfg(all(test, feature = "async"))] +mod request_token_tests { + use super::*; + + fn token(value: &[u8], kind: u64) -> crate::setup::Token { + crate::setup::Token { + kind, + value: value.to_vec(), + } + } + + fn subscribe_path() -> crate::PathOwned { + crate::Path::new("room/alice").to_owned() + } + + /// A request token reaches the app's acceptor like any other token, and the grant the + /// app answers is what the verdict resolves to. + #[moq_net_sim::test] + async fn a_request_token_reaches_the_acceptor() { + let handle = Handle::new(true); + let mut requests = handle.requests().expect("take the requests"); + let verdict = handle.verify_request(token(b"jwt", 0), None); + + let request = requests.next().await.expect("a request"); + assert_eq!(request.token().as_ref(), b"jwt"); + let _issued = request.accept(Grant::all()); + + let grant = verdict.grant().await.expect("granted"); + assert!(grant.publish.matches("room/alice")); + } + + /// An AUTH token has no type, so a request token of any type but out-of-band cannot reach + /// the acceptor as one: it is unsupported, never handed over as if it were a plain token. + #[moq_net_sim::test] + async fn a_typed_request_token_is_unsupported() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"cat", crate::setup::Token::CAT), None); + let err = verdict.grant().await.expect_err("a typed token"); + assert!(matches!(err.error, Error::Unsupported), "{err:?}"); + assert!( + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "nothing reached the acceptor" + ); + } + + /// An empty request token is refused before the acceptor, which would read it as the + /// connection's own credential. + #[test] + fn an_empty_request_token_is_unsupported() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"", crate::setup::Token::OUT_OF_BAND), None); + assert!(matches!( + verdict.poll_grant(&kio::Waiter::noop()), + Poll::Ready(Err(Refusal { + error: Error::Unsupported, + .. + })) + )); + assert!( + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "nothing reached the acceptor" + ); + } + + /// A request-borne token is verified even without the MoQ Auth extension: it rides the + /// request message, not the AUTH stream, so `requests()` stays live on a session whose + /// `supported` is false, even once the peer turns out not to speak AUTH. This is the shape + /// a standard moq-transport peer presents when it does not negotiate the AUTH extension. + #[moq_net_sim::test] + async fn a_request_token_is_verified_without_the_auth_extension() { + let handle = Handle::new(false); + let mut requests = handle.requests().expect("take the requests"); + handle.unsupported(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let request = requests.next().await.expect("a request reaches the acceptor"); + let _issued = request.accept(Grant::all()); + assert!( + verdict.grant().await.expect("granted").publish.matches("room/alice"), + "admitted without the AUTH extension" + ); + } + + /// With no `requests()` consumer, a request token cannot be verified in band, so the verdict + /// is Unsupported and the caller refuses the request NOT_SUPPORTED. + #[moq_net_sim::test] + async fn no_consumer_refuses_a_request_token_as_unsupported() { + let handle = Handle::new(true); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let err = verdict.grant().await.expect_err("no verifier"); + assert!(matches!(err.error, Error::Unsupported), "{err:?}"); + } + + /// A refused request token resolves to the refusal, which the caller sends the peer as + /// UNAUTHORIZED. + #[moq_net_sim::test] + async fn a_refused_request_token_is_unauthorized() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); + let err = verdict.grant().await.expect_err("refused"); + assert!( + matches!(err.error, Error::Session(SessionError::Unauthorized)), + "{err:?}" + ); + } + + /// A grant the acceptor revoked, dropped or replaced before the verdict was read is admitted + /// as it stands now, never as first issued. + #[moq_net_sim::test] + async fn a_grant_ended_before_admission_is_refused() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + + let verdict = handle.verify_request(token(b"jwt", 0), None); + let issued = requests.next().await.unwrap().accept(Grant::all()); + issued.revoke(SessionError::Unauthorized, "revoked"); + let err = verdict.grant().await.expect_err("revoked before admission"); + assert_eq!(err.reason, "revoked"); + + let verdict = handle.verify_request(token(b"jwt", 0), None); + drop(requests.next().await.unwrap().accept(Grant::all())); + let err = verdict.grant().await.expect_err("dropped before admission"); + assert!( + matches!(err.error, Error::Session(SessionError::Unauthorized)), + "{err:?}" + ); + + let verdict = handle.verify_request(token(b"jwt", 0), None); + let issued = requests.next().await.unwrap().accept(Grant::all()); + issued.update(Grant::default()); + let grant = verdict.grant().await.expect("still granted"); + assert!( + !grant.publish.matches("room/alice"), + "the replacement is what is admitted" + ); + } + + /// The grant lives exactly as long as the request: dropping the verdict ends the + /// acceptor's issued grant, so it never outlives the request nor joins the union. + #[moq_net_sim::test] + async fn dropping_the_verdict_ends_the_grant() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + drop(verdict); + let err = issued.closed().await; + assert!(matches!(err, Error::Cancel), "{err:?}"); + } + + /// A request token never joins the session union: the union stays what the connection + /// credential earned, not what a request token granted. + #[moq_net_sim::test] + async fn a_request_token_never_joins_the_union() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let _issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + // The union only reflects tokens presented on this side (none here), never a + // request token the acceptor answered. + assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + } + + fn subscribe_only(prefix: &str) -> Grant { + Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::subtree(prefix).unwrap().into(), + expires: None, + } + } + + /// The session's scope for unnamed requests is the connection credential's grant and every + /// grant issued to an AUTH token, and nothing a request token earned: a token on one + /// request never widens what tokenless requests may do. + #[moq_net_sim::test] + async fn the_scope_is_session_tokens_only() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + handle.authorize(&subscribe_only("room/alice")); + assert!(!handle.allows(Direction::Publish, "room/bob")); + + // A token on a request admits that request, and nothing else. + let verdict = handle.verify_request(token(b"request", 0), None); + let _request = requests.next().await.unwrap().accept(subscribe_only("room/bob")); + verdict.grant().await.expect("granted"); + assert!( + !handle.allows(Direction::Publish, "room/bob"), + "a request token widened the scope" + ); + + // An AUTH token joins the scope while its grant stands. + let serving = Serving::new(handle.clone()); + requests + .queue + .push(Request::new(Bytes::from_static(b"auth"), serving.issue.clone())); + let issued = requests.next().await.unwrap().accept(subscribe_only("room/carol")); + assert!(handle.allows(Direction::Publish, "room/carol")); + assert!( + handle.allows(Direction::Publish, "room/alice"), + "the connection grant still stands" + ); + + issued.update(subscribe_only("room/dave")); + assert!( + !handle.allows(Direction::Publish, "room/carol"), + "the update replaced it" + ); + assert!(handle.allows(Direction::Publish, "room/dave")); + + // Withdrawn by the peer, the grant leaves the scope, and a late update cannot bring it back. + serving.end(Error::Cancel); + assert!(!handle.allows(Direction::Publish, "room/dave")); + issued.update(subscribe_only("room/dave")); + assert!( + !handle.allows(Direction::Publish, "room/dave"), + "a withdrawn token rejoined the scope" + ); + } + + /// Revoking or dropping an AUTH token's grant takes it out of the scope. + #[moq_net_sim::test] + async fn an_ended_auth_grant_leaves_the_scope() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + handle.authorize(&Grant::default()); + for revoke in [true, false] { + let serving = Serving::new(handle.clone()); + requests + .queue + .push(Request::new(Bytes::from_static(b"auth"), serving.issue.clone())); + let issued = requests.next().await.unwrap().accept(subscribe_only("room")); + assert!(handle.allows(Direction::Publish, "room/alice")); + match revoke { + true => issued.revoke(SessionError::Unauthorized, "revoked"), + false => drop(issued), + } + assert!(!handle.allows(Direction::Publish, "room/alice"), "revoke={revoke}"); + } + } + + /// A session never authorized is bound by its origin handles alone, whatever its AUTH + /// tokens were granted: the scope only exists once the connection credential has a grant. + #[moq_net_sim::test] + async fn an_unauthorized_session_has_no_scope() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let serving = Serving::new(handle.clone()); + requests + .queue + .push(Request::new(Bytes::from_static(b"auth"), serving.issue.clone())); + let _issued = requests.next().await.unwrap().accept(subscribe_only("room")); + assert!(handle.allows(Direction::Publish, "other")); + } + + use std::time::Duration; + + fn grant_in(runtime: &crate::time::Clock, secs: u64) -> Grant { + Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: runtime.now().checked_add(Duration::from_secs(secs)), + } + } + + /// The request grant carries the acceptor's expiry, which is the deadline it lapses at. + #[moq_net_sim::test] + async fn a_request_grant_has_the_acceptors_expiry() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let grant = grant_in(&runtime, 60); + let expires = grant.expires; + let _issued = requests.next().await.unwrap().accept(grant); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + assert_eq!(request_grant.grant().expires, expires); + assert!(expires.is_some()); + } + + /// A grant that has already expired never admits the request or renews it, even for the + /// moment before its deadline would fire. + #[moq_net_sim::test] + async fn an_already_expired_grant_is_refused() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let expired = || Grant { + expires: Some(runtime.now()), + ..grant_in(&runtime, 0) + }; + + let verdict = handle.verify_request(token(b"old", 0), None); + let _issued = requests.next().await.unwrap().accept(expired()); + let answered = verdict.grant().await; + let refused = RequestGrant::new( + &runtime, + verdict, + answered, + subscribe_path(), + RequestKind::Subscribe, + None, + ); + assert!( + matches!(&refused, Err(refusal) if matches!(refusal.error, Error::Unauthorized)), + "an expired grant admits nothing" + ); + + let verdict = handle.verify_request(token(b"jwt", 0), None); + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await; + let request_grant = RequestGrant::new( + &runtime, + verdict, + answered, + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + let renewal = handle.verify_request(token(b"old2", 0), None); + let _issued = requests.next().await.unwrap().accept(expired()); + let renewed = renewal.grant().await; + assert!( + request_grant.renew(renewal, renewed).is_err(), + "an expired grant renews nothing" + ); + } + + /// An accepted REQUEST_UPDATE replaces the grant and its expiry. + #[moq_net_sim::test] + async fn a_renewal_token_replaces_the_grant_and_expiry() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + + let verdict = handle.verify_request(token(b"jwt", 0), None); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. + let renewal = handle.verify_request(token(b"jwt2", 0), None); + let second = grant_in(&runtime, 600); + let second_expires = second.expires; + let _issued2 = requests.next().await.unwrap().accept(second); + let renewed = renewal.grant().await; + assert!( + request_grant.renew(renewal, renewed).is_ok(), + "a covering renewal renews" + ); + + assert_eq!(request_grant.grant().expires, second_expires); + assert_ne!(second_expires, first_expires); + } + + /// With no accepted renewal, the deadline ends the request UNAUTHORIZED, and the + /// session is not closed by it. + #[moq_net_sim::test] + async fn the_deadline_ends_the_request_unauthorized_without_renewal() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + // Held for the request's life, so only the deadline (not a drop) ends it. + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } + + /// A refused renewal verdict is not applied: the request grant it already holds stands + /// untouched and ends only when its own deadline lapses. The serve loops end the request on + /// a refusal separately (a refused renewal ends only that request); this covers only that + /// the grant object is not mutated. + #[moq_net_sim::test] + async fn a_refused_renewal_leaves_the_grant() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + // The renewal token is refused, so it renews nothing and the old grant is untouched. + let renewal = handle.verify_request(token(b"bad", 0), None); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "bad token"); + let refused = renewal.grant().await; + assert!( + matches!(&refused, Err(refusal) if matches!(refusal.error, Error::Session(SessionError::Unauthorized)) && refusal.reason == "bad token"), + "the acceptor's code and reason reach the caller: {refused:?}" + ); + assert!( + request_grant.renew(renewal, refused).is_err(), + "a refusal does not renew" + ); + + // The old grant still stands with its original expiry, and the request ends only + // when that lapses. + assert_eq!(request_grant.grant().expires, first_expires); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// An acceptor-side update that no longer covers the request ends it, even when the + /// replacement has no expiry to lapse at; one that still covers it is folded in. + #[moq_net_sim::test] + async fn an_update_that_stops_covering_ends_the_request() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + // A covering update (everything, still unexpiring) leaves the request standing. + issued.update(Grant::all()); + let pending = kio::wait(|waiter| Poll::Ready(request_grant.poll_ended(waiter).is_pending())).await; + assert!(pending, "a covering update keeps the request"); + + issued.update(Grant::default()); + let err = moq_net_sim::timeout( + Duration::from_secs(1), + kio::wait(|waiter| request_grant.poll_ended(waiter)), + ) + .await + .expect("an uncovering update must end the request") + .error; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// The grant is the presenting peer's: a read is covered by `subscribe`, an announce by + /// `publish`, never the other way around. + #[test] + fn request_coverage_is_from_the_presenters_side() { + let read_only = Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = Grant { + publish: crate::Pattern::all().into(), + subscribe: Patterns::new(), + expires: None, + }; + assert!(RequestKind::Subscribe.covers(&read_only, "room")); + assert!(!RequestKind::Subscribe.covers(&write_only, "room")); + assert!(RequestKind::PublishNamespace.covers(&write_only, "room")); + assert!(!RequestKind::PublishNamespace.covers(&read_only, "room")); + } + + /// An announce routes its whole namespace, so a grant naming only the namespace itself + /// does not cover it, while one naming its subtree does. + #[test] + fn an_announce_needs_its_whole_subtree() { + let exact = Grant { + publish: crate::Pattern::literal("room").unwrap().into(), + subscribe: Patterns::new(), + expires: None, + }; + let subtree = Grant { + publish: crate::Pattern::subtree("room").unwrap().into(), + subscribe: Patterns::new(), + expires: None, + }; + assert!(!RequestKind::PublishNamespace.covers(&exact, "room")); + assert!(RequestKind::PublishNamespace.covers(&subtree, "room")); + assert!(RequestKind::PublishNamespace.covers(&subtree, "room/cam")); + assert!(!RequestKind::PublishNamespace.covers(&subtree, "other")); + // A SUBSCRIBE names one broadcast, which the exact grant's twin covers. + let read = Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::literal("room").unwrap().into(), + expires: None, + }; + assert!(RequestKind::Subscribe.covers(&read, "room")); + } + + /// An acceptor-side revoke ends the request before the deadline, and does not close the + /// session. + #[moq_net_sim::test] + async fn an_acceptor_revoke_ends_the_request() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), None); + // A grant that never expires, so only the revoke can end the request. + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + + issued.revoke(SessionError::Unauthorized, "revoked"); + let refusal = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!( + matches!(refusal.error, Error::Session(SessionError::Unauthorized)), + "{refusal:?}" + ); + assert_eq!(refusal.reason, "revoked", "the acceptor's reason is kept"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } +} diff --git a/rs/moq-net/src/coding/decode.rs b/rs/moq-net/src/coding/decode.rs index df93fcde50..dd756e7034 100644 --- a/rs/moq-net/src/coding/decode.rs +++ b/rs/moq-net/src/coding/decode.rs @@ -104,6 +104,11 @@ pub enum DecodeError { /// another, which rejects the request rather than closing the session. #[error("malformed authorization token")] MalformedAuthToken, + + /// A request carries more than one distinct `AUTHORIZATION TOKEN`, which this + /// implementation refuses as unsupported rather than closing the session. + #[error("multiple authorization tokens")] + MultipleAuthTokens, } impl DecodeError { diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index cf20c5af1b..a913cf80f5 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -61,6 +61,11 @@ pub enum SessionError { #[error("unknown auth token alias")] UnknownAuthTokenAlias, + /// The peer left more unacknowledged REQUEST_UPDATEs outstanding on one request stream + /// than the MAX_REQUEST_UPDATES it was advertised (draft-19 section 10.3.1.7). + #[error("too many request updates")] + TooManyRequestUpdates, + /// An application-chosen code, offset into the 64+ range on the wire. #[error("app code={0}")] App(u16), @@ -95,6 +100,7 @@ impl SessionError { Self::Version => 0x15, Self::UnknownAuthTokenAlias => 0x17, Self::Unsupported => 0x1, + Self::TooManyRequestUpdates => 0x1B, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -119,6 +125,7 @@ impl SessionError { 0x13 => Self::AuthTokenCacheOverflow, 0x15 => Self::Version, 0x17 => Self::UnknownAuthTokenAlias, + 0x1B => Self::TooManyRequestUpdates, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -697,6 +704,7 @@ mod tests { SessionError::AuthTokenCacheOverflow, SessionError::Version, SessionError::UnknownAuthTokenAlias, + SessionError::TooManyRequestUpdates, SessionError::App(0), SessionError::App(404), ]; @@ -716,6 +724,7 @@ mod tests { assert_eq!(SessionError::AuthTokenCacheOverflow.to_code(), 0x13); assert_eq!(SessionError::Version.to_code(), 0x15); assert_eq!(SessionError::UnknownAuthTokenAlias.to_code(), 0x17); + assert_eq!(SessionError::TooManyRequestUpdates.to_code(), 0x1B); // The reserved 32-47 range, and anything else unregistered, keeps its value instead // of being given a meaning. A peer on the old placeholders (0x20-0x22) lands here. diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index bb068cb397..6a39dbc9ed 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -608,6 +608,7 @@ impl Default for Messages { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }, // Draft-17+ carries the request id in the control message framing instead. ietf_ok: ietf::SubscribeOk { diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index b954e8ab63..03ab85c3f7 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1347,8 +1347,10 @@ mod tests { subscription_request_id: Some(target_id), start_location: ietf::Location { group: 1, object: 2 }, end_group: 100, - subscriber_priority: 200, - forward: false, + subscriber_priority: Some(200), + forward: Some(false), + filter: Some(ietf::Filter::NextObject), + authorization_token: None, }; let body = encode_body(&update, version); let raw = encode_raw(ietf::SubscribeUpdate::ID, &body, version); @@ -1519,6 +1521,7 @@ mod tests { request_id, track_namespace: crate::Path::new(namespace), cluster: None, + authorization_token: None, } } @@ -1880,8 +1883,10 @@ mod tests { subscription_request_id: Some(RequestId(0)), start_location: ietf::Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 1, - forward: true, + subscriber_priority: Some(1), + forward: Some(true), + filter: Some(ietf::Filter::NextObject), + authorization_token: None, }; let update = encode_raw(ietf::SubscribeUpdate::ID, &encode_body(&update, VERSION), VERSION); // The target is in the window and the update's own ID is past it. diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 4b726a1fd1..6a495eaf1d 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -318,7 +318,10 @@ pub(crate) mod request { Error::Timeout | Error::Stream(StreamError::DeliveryTimeout) | Error::Session(SessionError::Timeout) => { return TIMEOUT; } - Error::Unsupported | Error::Version | Error::Session(SessionError::Version | SessionError::Unsupported) => { + Error::Unsupported + | Error::Version + | Error::Decode(DecodeError::MultipleAuthTokens) + | Error::Session(SessionError::Version | SessionError::Unsupported) => { return NOT_SUPPORTED; } // moq-transport has no code for a FETCH that reached a datagram, which is never diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 53f5df5283..ed59c5600e 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -203,7 +203,7 @@ impl Message for Fetch<'_> { let subscriber_priority = buf.u8()?; let group_order = GroupOrder::decode(buf, version)?; let fetch_type = FetchType::decode(buf, version)?; - super::token::unique(&Parameters::skip_request(buf)?)?; + super::token::single(Parameters::skip_request(buf)?)?; ( fetch_type, Some(subscriber_priority), @@ -225,7 +225,7 @@ impl Message for Fetch<'_> { 0x27 => priority_filter: Vec where has_range_filters(version), 0x28 => object_property_filter: Vec where has_range_filters(version), ); - super::token::unique(&authorization_token)?; + super::token::single(authorization_token)?; let range_filters = [ subgroup_filter, object_id_filter, @@ -260,7 +260,7 @@ impl Message for Fetch<'_> { 0x28 => object_property_filter: Vec, 0x35 => include_properties: Option, ); - super::token::unique(&authorization_token)?; + super::token::single(authorization_token)?; let range_filters = [ subgroup_filter, object_id_filter, diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 1834293bb2..af1f716231 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -37,6 +37,7 @@ mod publisher; mod request; #[cfg(feature = "async")] mod request_stream; +mod request_update; #[cfg(feature = "async")] mod session; pub mod solicit; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 6a64130f68..500395eb8d 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -17,6 +17,8 @@ pub enum ParameterVarInt { /// Removed in draft-17; only used in draft-14/15/16. MaxRequestId = 2, MaxAuthTokenCacheSize = 4, + /// MAX_REQUEST_UPDATES, added in draft-19. + MaxRequestUpdates = super::request_update::OPTION, /// HOP_ID, from the MoQ Cluster extension. HopId = super::cluster::HOP_ID, /// RELAY_COST, from the MoQ Cluster extension. @@ -87,7 +89,13 @@ impl Decode for Parameters { // Unknown SETUP options may repeat, including GREASE; their values still // have to be well-formed Key-Value-Pairs (draft-21 section 9.1). if kind % 2 == 0 { - let kind = ParameterVarInt::from(kind); + let kind = match ParameterVarInt::from(kind) { + // Undefined before draft 19, so there an unknown option that may repeat. + ParameterVarInt::MaxRequestUpdates if !super::request_update::supported(version) => { + ParameterVarInt::Unknown(kind) + } + kind => kind, + }; if !matches!(kind, ParameterVarInt::Unknown(_)) && params.get_varint(kind).is_some() { return Err(DecodeError::Duplicate); } @@ -611,6 +619,22 @@ mod tests { use super::super::Filter; use super::*; + /// Before draft 19, 0x08 is an unknown SETUP option, so a peer may repeat it. + #[test] + fn max_request_updates_is_unknown_before_draft_19() { + // Draft 18: no count prefix, delta-encoded types, so the repeat is a delta of 0. + let block = [0x08, 0x01, 0x00, 0x02]; + let mut r = Decoder::new(&block, Version::Draft18.into()); + let params = Parameters::decode(&mut r, Version::Draft18).expect("a repeated unknown option is legal"); + assert_eq!(params.get_varint(ParameterVarInt::MaxRequestUpdates), None); + + let mut r = Decoder::new(&block, Version::Draft19.into()); + assert!(matches!( + Parameters::decode(&mut r, Version::Draft19), + Err(DecodeError::Duplicate) + )); + } + #[test] fn setup_allows_repeated_unknown_options() { for version in [ @@ -1276,7 +1300,9 @@ mod tests { 0x3E, 0x06, // and again ]; let mut r = Decoder::new(&block, Version::Draft14.into()); - Parameters::skip_request(&mut r).unwrap(); + let tokens = Parameters::skip_request(&mut r).unwrap(); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[1].0.kind, 1); assert!(r.is_empty()); } diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index a65e9fade4..c7030bdd31 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -314,7 +314,7 @@ impl Message for Publish<'_> { }; let forward = r.bool()?; // parameters - super::token::unique(&Parameters::skip_request(r)?)?; + super::token::single(Parameters::skip_request(r)?)?; Ok(Self { request_id, @@ -349,9 +349,9 @@ impl Message for Publish<'_> { 0x21 => _filter: Option where Filter::is_draft20(version), 0x22 => group_order: Option where version == Version::Draft15 || Filter::is_draft20(version), ); - super::token::unique(&authorization_token)?; - let mut properties = Properties::decode(r, version)?; + // Last, so a malformed field closes the session before a token refusal. + super::token::single(authorization_token)?; properties.group_order = properties.group_order.or(group_order); let forward = forward.unwrap_or(true); diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index a54e146125..a4e69cec6d 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -7,6 +7,7 @@ use crate::{Path, coding::*, ietf::RequestId}; use super::Message; use super::cluster; use super::namespace::{decode_namespace, encode_namespace}; +use super::token::RequestToken; use super::Version; @@ -21,6 +22,10 @@ pub struct PublishNamespace<'a> { /// negotiated the extension and `None` on one that did not, which is what decides /// whether they appear on the wire at all. pub cluster: Option, + + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. At most one is + /// accepted: decoding refuses a repeated or second token. + pub authorization_token: Option, } impl PublishNamespace<'_> { @@ -37,14 +42,11 @@ impl PublishNamespace<'_> { } let track_namespace = decode_namespace(r)?; - // The token decodes but grants nothing: the session's grant is what authorizes the request. decode_params!(r, version, 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - super::token::unique(&authorization_token)?; - let cluster = match negotiated { true => Some(cluster::Advert { hops: hops.ok_or(DecodeError::InvalidValue)?, @@ -55,11 +57,14 @@ impl PublishNamespace<'_> { false if hops.is_some() || cost.is_some() => return Err(DecodeError::InvalidValue), false => None, }; + // Last, so a malformed field closes the session before a token refusal. + let authorization_token = super::token::single(authorization_token)?; Ok(Self { request_id, track_namespace, cluster, + authorization_token, }) } } @@ -73,7 +78,18 @@ impl Message for PublishNamespace<'_> { w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_namespace(w, &self.track_namespace)?; - encode_cluster_params(w, version, self.cluster.as_ref()) + // ROUTE_COST is optional and absent means 0, so a free path sends nothing. + let hops = self.cluster.as_ref().map(|advert| advert.hops.clone()); + let cost = self + .cluster + .as_ref() + .and_then(|advert| (advert.cost != 0).then_some(advert.cost)); + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + cluster::HOP_PATH => hops, + cluster::ROUTE_COST => cost, + ); + Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { @@ -81,20 +97,26 @@ impl Message for PublishNamespace<'_> { } } -/// REQUEST_UPDATE (0x02) on a PUBLISH_NAMESPACE stream: the cluster parameters that -/// changed (draft-lcurley-moq-cluster, Updating an Advertisement). +/// REQUEST_UPDATE (0x02) on a PUBLISH_NAMESPACE: the cluster parameters that changed +/// (draft-lcurley-moq-cluster, Updating an Advertisement), or a renewed request token. /// /// An omitted parameter keeps its value (moq-transport Section 9.5), so a cost that /// dropped to 0 is sent as an explicit 0, unlike the advertisement itself where absent -/// means 0. Draft-17+ only: the extension negotiates on nothing earlier. +/// means 0. Draft-16+: the cluster extension negotiates on draft-17+ only, so draft-16 +/// carries only the token. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublishNamespaceUpdate { /// The update's own Request ID; every REQUEST_UPDATE consumes one. pub request_id: RequestId, + /// Draft-16 only: the PUBLISH_NAMESPACE this updates, which later drafts name by stream. + pub namespace_request_id: Option, /// HOP_PATH, when the route changed. pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, + /// The `AUTHORIZATION TOKEN` (0x03) renewing the advertisement's credential, if any. At + /// most one is accepted: decoding refuses a repeated or second token. + pub authorization_token: Option, } impl PublishNamespaceUpdate { @@ -102,8 +124,10 @@ impl PublishNamespaceUpdate { pub fn between(request_id: RequestId, held: &cluster::Advert, next: &cluster::Advert) -> Self { Self { request_id, + namespace_request_id: None, hops: (held.hops != next.hops).then(|| next.hops.clone()), cost: (held.cost != next.cost).then_some(next.cost), + authorization_token: None, } } @@ -121,7 +145,13 @@ impl Message for PublishNamespaceUpdate { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => return Err(EncodeError::Version), + Version::Draft14 | Version::Draft15 => return Err(EncodeError::Version), + Version::Draft16 => { + self.request_id.encode(w, version)?; + self.namespace_request_id + .ok_or(EncodeError::Version)? + .encode(w, version)?; + } Version::Draft17 => { self.request_id.encode(w, version)?; w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) @@ -129,6 +159,7 @@ impl Message for PublishNamespaceUpdate { _ => self.request_id.encode(w, version)?, } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), cluster::HOP_PATH => self.hops, cluster::ROUTE_COST => self.cost, ); @@ -136,23 +167,29 @@ impl Message for PublishNamespaceUpdate { } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { - let request_id = match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => return Err(DecodeError::Version), + let (request_id, namespace_request_id) = match version { + Version::Draft14 | Version::Draft15 => return Err(DecodeError::Version), + Version::Draft16 => (RequestId::decode(r, version)?, Some(RequestId::decode(r, version)?)), Version::Draft17 => { let request_id = RequestId::decode(r, version)?; let _required_request_id_delta = r.varint()?; - request_id + (request_id, None) } - _ => RequestId::decode(r, version)?, + _ => (RequestId::decode(r, version)?, None), }; - // The token decodes but grants nothing: the session's grant is what authorizes the request. decode_params!(r, version, 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - super::token::unique(&authorization_token)?; - Ok(Self { request_id, hops, cost }) + let authorization_token = super::token::single(authorization_token)?; + Ok(Self { + request_id, + namespace_request_id, + hops, + cost, + authorization_token, + }) } } @@ -369,6 +406,7 @@ mod tests { request_id: RequestId(1), track_namespace: Path::new("test/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -458,6 +496,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v17/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -473,6 +512,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v18/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -567,20 +607,34 @@ mod tests { assert_eq!(decoded.apply(&held), rerouted); } - /// The extension negotiates on draft-17+ only, so the update has no legacy shape. + /// Drafts 14-15 have no update for an advertisement, and draft-16's names the one it + /// updates. #[test] - fn update_has_no_legacy_encoding() { - let msg = PublishNamespaceUpdate { + fn update_names_the_advertisement_on_draft_16() { + let mut msg = PublishNamespaceUpdate { request_id: RequestId(2), + namespace_request_id: None, hops: None, - cost: Some(0), + cost: None, + authorization_token: Some(super::super::token::RequestToken(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })), }; - let mut buf = Vec::new(); - assert!( - msg.encode_msg(&mut Encoder::new(&mut buf, Version::Draft16.into()), Version::Draft16) - .is_err() - ); - assert!(decode_message::(&[0x02, 0x00], Version::Draft16).is_err()); + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let mut buf = Vec::new(); + assert!( + msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) + .is_err(), + "{version:?}" + ); + } + assert!(decode_message::(&[0x02, 0x00], Version::Draft15).is_err()); + + msg.namespace_request_id = Some(RequestId(1)); + let decoded: PublishNamespaceUpdate = + decode_message(&encode_message(&msg, Version::Draft16), Version::Draft16).unwrap(); + assert_eq!(decoded, msg); } #[test] diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index f4589c1a9e..b0d045cc02 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -22,7 +22,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox}, }; -use super::{Message, Version, cluster, error::request, peer}; +use super::{Message, Version, cluster, error::request, peer, request_update}; /// Largest millisecond duration every implementation can carry losslessly. const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; @@ -396,6 +396,42 @@ impl Namespaces { } } +/// How a subscription's serve loop ended, short of the peer cancelling it. +#[derive(Debug)] +enum Served { + /// The track and any fill finished, or serving ended with this error. + Track(Result<(), Error>), + /// An update could not be applied, its parameters unsupported or its renewal refused + /// (drafts 16 section 9.11.1, 18 section 10.9.1). The update was already answered, and + /// PUBLISH_DONE says UPDATE_FAILED, or on draft 14 the refusal itself. + UpdateFailed(Error), +} + +/// How an admitted subscription came out of serving. +enum Subscribed { + /// It never started, refused with this reason and error before SUBSCRIBE_OK. + Refused(String, Error), + /// It was served, and opened this many group streams. + Served(Served, u64), +} + +impl Served { + /// The PUBLISH_DONE status and reason this end reports. + fn status(&self, version: Version) -> (ietf::PublishDoneStatus, &'static str) { + match self { + // Draft 14 neither answers an update nor registers UPDATE_FAILED, so the + // PUBLISH_DONE is the only place the subscriber learns why its renewal failed. + Self::UpdateFailed(err) if version == Version::Draft14 => Self::Track(Err(err.clone())).status(version), + Self::Track(Ok(())) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), + Self::Track(Err(Error::Unauthorized | Error::Session(crate::SessionError::Unauthorized))) => { + (ietf::PublishDoneStatus::Unauthorized, "not granted") + } + Self::Track(Err(_)) => (ietf::PublishDoneStatus::InternalError, "internal error"), + Self::UpdateFailed(_) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), + } + } +} + /// What woke an announce-forwarding loop. enum NamespaceEvent { /// The session or stream ended, with the result to surface. @@ -464,6 +500,9 @@ enum Joined { cache: track::Consumer, /// The subscription's namespace, which the fetch is held to the grant on. namespace: crate::PathOwned, + /// The subscription's own request token authorized it, so the limit does not bound + /// the fetch either. + token: bool, /// The subscription's ranking, which the fetch joins. ranking: Ranking, }, @@ -609,8 +648,12 @@ where let task = match id { ietf::Subscribe::ID => { let msg = match ietf::Subscribe::decode_msg(&mut data, this.version) { - Err(DecodeError::MalformedAuthToken) => { - return Ok(this.reject_token(stream, request::Kind::Subscribe, body.clone(), serve)); + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } + return Ok(this.reject_token(stream, request::Kind::Subscribe, body.clone(), err, serve)); } msg => msg?, }; @@ -629,8 +672,12 @@ where } ietf::Fetch::ID => { let msg = match ietf::Fetch::decode_msg(&mut data, this.version) { - Err(DecodeError::MalformedAuthToken) => { - return Ok(this.reject_token(stream, request::Kind::Fetch, body.clone(), serve)); + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } + return Ok(this.reject_token(stream, request::Kind::Fetch, body.clone(), err, serve)); } msg => msg?, }; @@ -664,9 +711,13 @@ where }) }; let (msg, options) = match decoded { - Err(DecodeError::MalformedAuthToken) => { + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } let kind = request::Kind::SubscribeNamespace; - return Ok(this.reject_token(stream, kind, body.clone(), serve)); + return Ok(this.reject_token(stream, kind, body.clone(), err, serve)); } decoded => decoded?, }; @@ -710,8 +761,12 @@ where } ietf::TrackStatus::ID => { let msg = match ietf::TrackStatus::decode_msg(&mut data, this.version) { - Err(DecodeError::MalformedAuthToken) => { - return Ok(this.reject_token(stream, request::Kind::TrackStatus, body.clone(), serve)); + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } + return Ok(this.reject_token(stream, request::Kind::TrackStatus, body.clone(), err, serve)); } msg => msg?, }; @@ -741,12 +796,13 @@ where stream: Stream, kind: request::Kind, body: ietf::Body, + err: DecodeError, serve: Option, ) -> MaybeSendBox<'static, ()> { let version = self.version; async move { let _serve = serve; - if let Err(err) = super::token::reject(stream, kind, body, version).await { + if let Err(err) = super::token::reject(stream, kind, body, err, version).await { tracing::debug!(%err, "token refusal failed"); } } @@ -771,16 +827,22 @@ where // Serve only what our grant lets us publish (MoQ Auth), and stop once it no // longer does. Checked before resolving, so a denied request never reaches the - // origin. - let mut gate = crate::auth::Gate::new( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - ); - if !self - .auth - .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) - { + // origin. Our own authorization of the peer (the limit) admits a request without a + // token; an AUTHORIZATION TOKEN on the request goes to the application to verify for + // this request alone (MoQ request-token), even where the limit covers it. What the + // peer granted us bounds what we send either way, and never stands in for our + // authorization of it. + let path = msg.track_namespace.as_str(); + let token = match &msg.authorization_token { + Some(token) + if self.auth.within_grant(crate::auth::Direction::Publish, path) + && self.auth.token_decides(crate::auth::Direction::Publish) => + { + Some(token.0.clone()) + } + _ => None, + }; + if token.is_none() && !self.auth.allows(crate::auth::Direction::Publish, path) { let err = Error::Unauthorized; return self.reject_subscribe(stream, request_id, &err, "not granted").await; } @@ -798,9 +860,10 @@ where .reject_subscribe(stream, request_id, &Error::Unsupported, "range filters not supported") .await; } - // Held for the life of the subscription. A peer past its limits loses the session. - let _slot = match self.subscriptions.acquire() { - Ok(slot) => slot, + // Held for the life of the subscription, and while the application verifies its + // token. A peer past its limits loses the session. + let slot: crate::auth::Hold = match self.subscriptions.acquire() { + Ok(slot) => std::sync::Arc::new(slot), Err(err) => { self.session.clone().close( super::error::to_session_code(&crate::SessionError::from(&err)), @@ -810,207 +873,300 @@ where } }; - // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in - // the model, through the tagged `origin::Consumer` the broadcast resolves from. - - // We just received a subscribe for this exact namespace, so the peer must have already - // seen the announcement. `request_broadcast` resolves it immediately, or falls back to - // the route covering it (an `origin::Dynamic`), if any. - let broadcast = match self - .serving_origin() - .await - .request_broadcast(&msg.track_namespace, None) - .await - { - Ok(broadcast) => broadcast, - // The reason is the origin's, not ours: a dynamic router refusing on - // authorization or a drain says so, and only an unroutable path means the - // broadcast is not here. - Err(err) => { - return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; + let mut request_finished = false; + // A token-authorized subscription is held to its own grant, which ends it when the + // grant lapses, is revoked, or stops covering it. Every subscription is gated so it + // ends once what the peer granted us stops covering it, and a limit-authorized one + // once the limit does too. + let (mut gate, request_grant) = match token { + Some(token) => { + let verdict = self.auth.verify_request(token, Some(slot.clone())); + // The subscriber cancelling or the session closing withdraws the token. + let mut session = self.session.clone(); + let verified = kio::wait(|waiter| { + let mut cx = waiter.context(); + if session.poll_closed(&mut cx).is_ready() + || super::request_stream::poll_withdrawn(&mut stream, self.version, &mut cx).is_ready() + { + return Poll::Ready(None); + } + verdict.poll_grant(waiter).map(Some) + }) + .await; + let Some(verified) = verified else { + return Ok(()); + }; + let admitted = crate::auth::RequestGrant::new( + &self.runtime, + verdict, + verified, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + Some(slot.clone()), + ); + match admitted { + Ok(grant) => ( + crate::auth::Gate::granted( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ), + Some(grant), + ), + Err(refusal) => { + return self + .reject_subscribe(stream, request_id, &refusal.error, &refusal.reason) + .await; + } + } } + None => ( + crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ), + None, + ), }; + let _slot = slot; + + // Everything the subscription does once admitted, its setup included, runs under its + // authority: a grant that ends or a gate that closes at any await drops it right there, + // releasing what it holds upstream, and only then is the peer told. `acked` holds the + // group stream count once SUBSCRIBE_OK is buffered, which decides how. + let mut acked: Option> = None; + let subscribed = { + let serve = async { + // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in + // the model, through the tagged `origin::Consumer` the broadcast resolves from. + + // We just received a subscribe for this exact namespace, so the peer must have + // already seen the announcement. `request_broadcast` resolves it immediately, or + // falls back to the route covering it (an `origin::Dynamic`), if any. + let broadcast = match self + .serving_origin() + .await + .request_broadcast(&msg.track_namespace, None) + .await + { + Ok(broadcast) => broadcast, + // The reason is the origin's, not ours: a dynamic router refusing on + // authorization or a drain says so, and only an unroutable path means the + // broadcast is not here. + Err(err) => return Ok(Subscribed::Refused(err.to_string(), err)), + }; - let track = match broadcast.track(&msg.track_name) { - Ok(track) => track, - Err(err) => { - return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; - } - }; + let track = match broadcast.track(&msg.track_name) { + Ok(track) => track, + Err(err) => return Ok(Subscribed::Refused(err.to_string(), err)), + }; - let order = msg.group_order.to_order(); - let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); - - // Subscribe before resolving the filter: on a routed broadcast the live edge only - // becomes readable once the subscription's demand attaches a route, so the edge - // snapshot has to come after. The resolved range is applied to the preference - // right below, before anything is served. - let (cache, mut track) = { - match track.subscribe(subscription.clone()).await { - Ok(subscribed) => (track, subscribed), - Err(err) => { - return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; - } - } - }; + let order = msg.group_order.to_order(); + let mut subscription = serving_subscription(msg.subscriber_priority).with_order(order); - // A relay's copy that went idle cannot say where its live edge is until its route - // answers again; answering from its cache would advertise a stale one. - kio::wait(|waiter| track.poll_live(waiter)).await; - - // The filter and any fill are relative to the live edge, so snapshot it once: - // the fill ends exactly where a Next Object subscription begins, which is what - // lets the draft's current-group join (Next Object plus a StartGroup=1 fill) - // cover the group with no gap and no overlap. - let edge = live_edge(&cache); - let range = subscribe_range(&msg, edge, self.version); - subscription.start = range.start.map(|start| track::Position { - group: start.group, - frame: start.object, - }); - subscription.end = range.end.and_then(|end| match end.object { - Some(object) => track::Position::after(end.group, object), - None => track::Position::after_group(end.group), - }); - let _ = track.update(subscription); - // A Timestamp goes out wherever the draft can declare its units and the track has - // them. A subscriber that opted out of this SUBSCRIBE_OK's properties learns them from - // TRACK_STATUS, so the opt-out strips nothing from the objects. Drafts 14-16 never - // write TIMESCALE, and an untimed track declares none, so their objects stay unstamped. - let timescale = track - .info() - .timescale - .filter(|_| ietf::Properties::sends_timescale(self.version)); - - // Serve the track while reading updates; only abrupt closure cancels on draft-19+. - // A fill or joining FETCH ranks in the same subscription as the group it carries. - let mut track_serve = - TrackServe::new(self.session.clone(), track, request_id, self.version, range, timescale) - .ranked(self.priority.clone()); - - // Draft-20 replaced joining FETCH with subscription fills. Older drafts save - // the same boundary used by the subscription so the two streams never overlap. - if let Some(join) = &_join { - let joined = match (msg.filter, edge.largest) { - (Filter::NextObject, Some(largest)) => Joined::Group { - end: Location { - group: largest.group, - object: largest.object + 1, - }, - cache: cache.clone(), - namespace: msg.track_namespace.to_owned(), - ranking: track_serve.ranking.clone(), - }, - (Filter::NextObject, None) => Joined::Empty, - _ => Joined::Unsupported, - }; - join.joins.lock().insert(request_id, Some(joined)); - } + // Subscribe before resolving the filter: on a routed broadcast the live edge only + // becomes readable once the subscription's demand attaches a route, so the edge + // snapshot has to come after. The resolved range is applied to the preference + // right below, before anything is served. + let (cache, mut track) = match track.subscribe(subscription.clone()).await { + Ok(subscribed) => (track, subscribed), + Err(err) => return Ok(Subscribed::Refused(err.to_string(), err)), + }; - // A fill reads the group cache through its own consumer, independent of the - // subscription's cursor. - let fill = msg - .fill - .filter(|_| Filter::is_draft20(self.version)) - .map(|fill| (fill_range(fill, msg.filter, edge.largest), cache, timescale)); + // A relay's copy that went idle cannot say where its live edge is until its + // route answers again; answering from its cache would advertise a stale one. + kio::wait(|waiter| track.poll_live(waiter)).await; + + // The filter and any fill are relative to the live edge, so snapshot it once: + // the fill ends exactly where a Next Object subscription begins, which is what + // lets the draft's current-group join (Next Object plus a StartGroup=1 fill) + // cover the group with no gap and no overlap. + let edge = live_edge(&cache); + let range = subscribe_range(&msg, edge, self.version); + subscription.start = range.start.map(|start| track::Position { + group: start.group, + frame: start.object, + }); + subscription.end = range.end.and_then(|end| match end.object { + Some(object) => track::Position::after(end.group, object), + None => track::Position::after_group(end.group), + }); + let _ = track.update(subscription); + // A Timestamp goes out wherever the draft can declare its units and the track + // has them. A subscriber that opted out of this SUBSCRIBE_OK's properties learns + // them from TRACK_STATUS, so the opt-out strips nothing from the objects. Drafts + // 14-16 never write TIMESCALE, and an untimed track declares none, so their + // objects stay unstamped. + let timescale = track + .info() + .timescale + .filter(|_| ietf::Properties::sends_timescale(self.version)); + + // Serve the track while reading updates; only abrupt closure cancels on + // draft-19+. A fill or joining FETCH ranks in the same subscription as the group + // it carries. + let mut track_serve = + TrackServe::new(self.session.clone(), track, request_id, self.version, range, timescale) + .ranked(self.priority.clone()); + + // Draft-20 replaced joining FETCH with subscription fills. Older drafts save + // the same boundary used by the subscription so the two streams never overlap. + if let Some(join) = &_join { + let joined = match (msg.filter, edge.largest) { + (Filter::NextObject, Some(largest)) => Joined::Group { + end: Location { + group: largest.group, + object: largest.object + 1, + }, + cache: cache.clone(), + namespace: msg.track_namespace.to_owned(), + token: request_grant.is_some(), + ranking: track_serve.ranking.clone(), + }, + (Filter::NextObject, None) => Joined::Empty, + _ => Joined::Unsupported, + }; + join.joins.lock().insert(request_id, Some(joined)); + } - // Send SubscribeOk on the stream - stream.writer.varint(ietf::SubscribeOk::ID).await?; - stream - .writer - .encode(&ietf::SubscribeOk { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), - _ => None, - }, - track_alias: request_id.0, - // The subscription floor and FETCH/FILL cap use this same snapshot. - largest: edge.largest, - properties: { - let mut properties = track_properties(track_serve.track.info(), msg.properties_wanted); - // Through draft-15 this restates the order the subscription is served in. - // Later drafts carry the publisher's default instead. - if matches!(self.version, Version::Draft14 | Version::Draft15) { - properties.group_order = Some(order.into()); + // A fill reads the group cache through its own consumer, independent of the + // subscription's cursor. + let fill = msg + .fill + .filter(|_| Filter::is_draft20(self.version)) + .map(|fill| (fill_range(fill, msg.filter, edge.largest), cache, timescale)); + + // Buffered whole before its first await, so however the subscription is + // dropped from here, the request can still end on the wire. + acked = Some(track_serve.opened.clone()); + stream.writer.buffer_varint(ietf::SubscribeOk::ID)?; + stream + .writer + .encode(&ietf::SubscribeOk { + request_id: match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), + _ => None, + }, + track_alias: request_id.0, + // The subscription floor and FETCH/FILL cap use this same snapshot. + largest: edge.largest, + properties: { + let mut properties = track_properties(track_serve.track.info(), msg.properties_wanted); + // Through draft-15 this restates the order the subscription is served + // in. Later drafts carry the publisher's default instead. + if matches!(self.version, Version::Draft14 | Version::Draft15) { + properties.group_order = Some(order.into()); + } + properties + }, + }) + .await?; + + // The fill (when one was requested) runs alongside on its own fetch stream; + // its failures reset that stream and never touch the subscription. + let opened = track_serve.opened.clone(); + let ranking = track_serve.ranking.clone(); + let fill = async { + if let Some((fill, cache, timescale)) = fill { + self.run_fill(fill, &ranking, cache, timescale, &opened).await; } - properties - }, - }) - .await?; + }; + let served = self + .run_subscription( + &mut stream, + &mut track_serve, + &mut request_finished, + fill, + request_grant.clone(), + ) + .await; - let mut request_finished = false; + let completed = served.is_some(); + let served = served.unwrap_or(Served::Track(Ok(()))); - // The fill (when one was requested) runs alongside on its own fetch stream; - // its failures reset that stream and never touch the subscription. - let opened = track_serve.opened.clone(); - let ranking = track_serve.ranking.clone(); - let fill = async { - if let Some((fill, cache, timescale)) = fill { - self.run_fill(fill, &ranking, cache, timescale, &opened).await; - } - }; - // Ends the subscription once our grant (MoQ Auth) stops covering it. - let served = { - let serve = self.run_subscription(&mut stream, &mut track_serve, &mut request_finished, fill); - let mut serve = std::pin::pin!(serve); - kio::wait(|waiter| { - if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { - return Poll::Ready(served); + // Draft-14 on carries no end location in PUBLISH_DONE: an END_OF_TRACK object + // is what tells the subscriber where the track ended. A cancelled subscription + // is owed nothing more, and one cancelled while the marker waits for stream + // credit abandons it. + if completed + && matches!(served, Served::Track(Ok(()))) + && let Some(end) = track_serve.end() + { + let mut marker = std::pin::pin!(track_serve.write_end_of_track(end)); + let mut closed_session = self.session.clone(); + let written = kio::wait(|waiter| { + if let Poll::Ready(res) = waiter.poll_future(marker.as_mut()) { + return Poll::Ready(res); + } + let mut cx = waiter.context(); + if super::request_stream::poll_cancel( + &mut stream, + &mut request_finished, + self.version, + &mut cx, + ) + .is_ready() || closed_session.poll_closed(&mut cx).is_ready() + { + return Poll::Ready(Err(Error::Cancel)); + } + Poll::Pending + }) + .await; + // A failure only costs the subscriber the early boundary. + if let Err(err) = written { + tracing::debug!(%err, id = %request_id, "end of track failed"); + } + } + + // PUBLISH_DONE must follow the close of every data stream. A subscription that + // completed has drained its groups; one that ended early resets the rest here. + // The fill stream closed when `run_subscription` dropped it. + track_serve.close_groups(); + Ok::<_, Error>(Subscribed::Served(served, track_serve.opened())) + }; + crate::auth::until(serve, |waiter| { + if let Some(grant) = &request_grant + && let Poll::Ready(refusal) = grant.poll_ended(waiter) + { + // PUBLISH_DONE carries a fixed reason per status, so only the code reaches + // the peer once SUBSCRIBE_OK went out. + tracing::info!(broadcast = %absolute, track = %track_name, err = %refusal.error, reason = %refusal.reason, "request token grant ended"); + return Poll::Ready(refusal); } if gate.poll_denied(waiter).is_ready() { tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); - return Poll::Ready(Some(Err(Error::Unauthorized))); + return Poll::Ready(Error::Unauthorized.into()); } Poll::Pending }) .await }; + // A FETCH joining this subscription ends with it, before the peer reads a word. + drop(_join); - let completed = served.is_some(); - let res = served.unwrap_or(Ok(())); - - // Draft-14 on carries no end location in PUBLISH_DONE: an END_OF_TRACK object is - // what tells the subscriber where the track ended. A cancelled subscription is - // owed nothing more, and one cancelled while the marker waits for stream credit - // abandons it. - if completed - && res.is_ok() - && let Some(end) = track_serve.end() - { - let mut marker = std::pin::pin!(track_serve.write_end_of_track(end)); - let mut closed_session = self.session.clone(); - let written = kio::wait(|waiter| { - if let Poll::Ready(res) = waiter.poll_future(marker.as_mut()) { - return Poll::Ready(res); - } - let mut cx = waiter.context(); - if super::request_stream::poll_cancel(&mut stream, &mut request_finished, self.version, &mut cx) - .is_ready() || closed_session.poll_closed(&mut cx).is_ready() - { - return Poll::Ready(Err(Error::Cancel)); - } - Poll::Pending - }) - .await; - // A failure only costs the subscriber the early boundary. - if let Err(err) = written { - tracing::debug!(%err, id = %request_id, "end of track failed"); + let (served, streams) = match subscribed { + Ok(Ok(Subscribed::Served(served, streams))) => (served, streams), + Ok(Ok(Subscribed::Refused(reason, err))) => { + return self.reject_subscribe(stream, request_id, &err, &reason).await; } - } - - // PUBLISH_DONE must follow the close of every data stream. A subscription that - // completed has drained its groups; one that ended early resets the rest here. - // The fill stream closed when `run_subscription` dropped it. - track_serve.close_groups(); - let streams = track_serve.opened(); + Ok(Err(err)) => return Err(err), + Err(refusal) => match acked { + Some(opened) => (Served::Track(Err(refusal.error)), opened.load(Ordering::Relaxed)), + None => { + return self + .reject_subscribe(stream, request_id, &refusal.error, &refusal.reason) + .await; + } + }, + }; + let (status, reason) = served.status(self.version); + let res = match served { + Served::Track(res) => res, + Served::UpdateFailed(err) => Err(err), + }; // Send PublishDone - let (status, reason) = match &res { - Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), - Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), - Err(Error::Unsupported) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), - Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), - }; let _ = stream.writer.varint(ietf::PublishDone::ID).await; let _ = stream .writer @@ -1038,32 +1194,73 @@ where serve: &mut TrackServe, finished: &mut bool, fill: impl std::future::Future, - ) -> Option> { + request_grant: Option, + ) -> Option { let mut session = self.session.clone(); let mut fill = std::pin::pin!(fill); let mut fill_done = false; let mut served = None; + + // Renewal state carried across turns of the loop. After SUBSCRIBE_OK the peer may send a + // REQUEST_UPDATE refreshing the request's token. A token-bearing update's verify is raced + // against serving (never a bare await), so media keeps flowing while the acceptor is + // slow; the caller's guard still ends the request when the old grant does. While a verdict is + // pending the loop keeps reading so a cancellation still ends the request, stashing every + // further update, token or not, to handle once the verdict resolves: draft-17+ answers + // carry no Request ID, so they must go out in the order the updates arrived. The stash is + // a FIFO bounded by MAX_REQUEST_UPDATES (counting the one being verified). + let mut renewal = request_update::Renewal::::default(); + + // What one turn of the loop resolved to. + enum Turn { + // The track and any fill finished, or the stream failed. + Served(Result<(), Error>), + // The peer cancelled, or the session ended. + Cancelled, + // A REQUEST_UPDATE, already decoded. + Update(super::request_stream::Update), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(request_update::Verdict), + } + loop { - let event = kio::wait(|waiter| { + // Handle one queued update before reading the next, so answers stay in order. + if let Some(update) = renewal.next() { + if let Err(served) = self + .apply_subscription_update(stream, serve, &request_grant, &mut renewal, update) + .await + { + return Some(served); + } + continue; + } + + let turn = kio::wait(|waiter| { let mut cx = waiter.context(); if session.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Cancel)); + return Poll::Ready(Turn::Cancelled); } if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) && stream.writer.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Cancel)); + return Poll::Ready(Turn::Cancelled); + } + if let Poll::Ready(renewed) = renewal.poll(waiter) { + return Poll::Ready(Turn::Renewal(renewed)); } if !*finished { if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { use super::request_stream::{FollowUp, Update}; match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(FollowUp::Update(body)))) => { - return Poll::Ready(Update::decode_legacy(&body, self.version).map(Some)); + return Poll::Ready(match Update::decode_legacy(&body, self.version) { + Ok(update) => Turn::Update(update), + Err(err) => Turn::Served(Err(err)), + }); } // UNSUBSCRIBE, or the control stream going away. - Poll::Ready(Ok(Some(FollowUp::End) | None)) => return Poll::Ready(Err(Error::Cancel)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(Some(FollowUp::End) | None)) => return Poll::Ready(Turn::Cancelled), + Poll::Ready(Err(err)) => return Poll::Ready(Turn::Served(Err(err))), Poll::Pending => {} } } else { @@ -1077,15 +1274,15 @@ where super::error::to_session_code(&crate::SessionError::from(&err)), "invalid request ID", ); - return Poll::Ready(Err(err)); + return Poll::Ready(Turn::Served(Err(err))); } - return Poll::Ready(Ok(Some(update))); + return Poll::Ready(Turn::Update(update)); } Poll::Ready(Ok(None)) if !super::request_stream::fin_cancels(self.version) => { *finished = true } - Poll::Ready(Ok(None)) => return Poll::Ready(Err(Error::Cancel)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(None)) => return Poll::Ready(Turn::Cancelled), + Poll::Ready(Err(err)) => return Poll::Ready(Turn::Served(Err(err))), Poll::Pending => {} } } @@ -1099,71 +1296,162 @@ where served = Some(done); } match (&served, fill_done) { - (Some(Ok(())), true) => Poll::Ready(Ok(None)), - (Some(Err(err)), true) => Poll::Ready(Err(err.clone())), + (Some(Ok(())), true) => Poll::Ready(Turn::Served(Ok(()))), + (Some(Err(err)), true) => Poll::Ready(Turn::Served(Err(err.clone()))), _ => Poll::Pending, } }) .await; - match event { - Ok(None) => return Some(Ok(())), - Err(Error::Cancel) => return None, + + // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, + // naming the update's own Request ID; later drafts name none. + let answer = self.version != Version::Draft14; + + match turn { // An update the draft answers by closing the session, such as one whose // AUTHORIZATION TOKEN names an alias, closes it rather than only ending this // subscription, as the same token on the request itself would. - Err(err @ Error::Decode(crate::coding::DecodeError::Session(_))) => { + Turn::Served(Err(err @ Error::Decode(crate::coding::DecodeError::Session(_)))) => { self.session.clone().close( super::error::to_session_code(&crate::SessionError::from(&err)), &err.to_string(), ); - return Some(Err(err)); + return Some(Served::Track(Err(err))); } - Err(err) => return Some(Err(err)), - Ok(Some(update)) => { - // Draft 14 answers no update. Drafts 15 and 16 answer on the control - // stream, naming the update's own Request ID; later drafts name none. - let answer = self.version != Version::Draft14; - let answer_id = - matches!(self.version, Version::Draft15 | Version::Draft16).then_some(update.request_id); - if let Some(err) = update.refused { - let reason = match err { - Error::Unsupported => "REQUEST_UPDATE parameters not supported", - _ => "repeated AUTHORIZATION TOKEN", - }; - let result = if answer { - self.write_subscribe_error(&mut stream.writer, update.request_id, &err, reason) - .await - } else { - Ok(()) - }; - // Either way the update failed, which ends the subscription. - return Some(result.and(Err(Error::Unsupported))); - } - if let Some(priority) = update.priority - && let Err(err) = serve.set_priority(super::priority::from_wire(priority)) + Turn::Served(res) => return Some(Served::Track(res)), + Turn::Cancelled => return None, + Turn::Renewal(request_update::Verdict { + verdict, + grant: res, + request_id: rid, + }) => { + // Only a token-authorized request starts a renewal. + let rg = request_grant.as_ref().expect("a renewal without a request grant"); + // On accept the old grant is dropped (ending the old token) and the deadline + // re-armed at the new expiry. + let Err(refusal) = rg.renew(verdict, res) else { + if answer && let Err(err) = self.write_request_ok(&mut stream.writer, rid).await { + return Some(Served::Track(Err(err))); + } + continue; + }; + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // renewal ends only this subscription: REQUEST_ERROR, then PUBLISH_DONE + // UPDATE_FAILED. The session stays up and the old grant does not survive. + if answer + && let Err(err) = self + .write_subscribe_error(&mut stream.writer, rid, &refusal.error, &refusal.reason) + .await { - return Some(Err(err)); + return Some(Served::Track(Err(err))); } - if answer - && let Err(err) = async { - stream.writer.varint(ietf::RequestOk::ID).await?; - stream - .writer - .encode(&ietf::RequestOk { - request_id: answer_id, - active: None, - }) - .await - } + return Some(Served::UpdateFailed(refusal.error)); + } + // An update behind a pending verdict waits its turn, so its answer follows the + // verdict's. + Turn::Update(update) if renewal.pending() => match renewal.queue(update, self.version) { + Ok(()) => {} + Err(request_update::Overflow::Session) => { + return Some(Served::Track(Err(request_update::too_many(&mut self.session.clone())))); + } + // Below draft 19 no limit is negotiated, so this is a local memory guard: end + // this request, never the session. + Err(request_update::Overflow::Request) => { + return Some(Served::Track(Err(Error::ProtocolViolation))); + } + }, + Turn::Update(update) => { + if let Err(served) = self + .apply_subscription_update(stream, serve, &request_grant, &mut renewal, update) .await { - return Some(Err(err)); + return Some(served); } } } } } + /// Apply one REQUEST_UPDATE on an in-flight subscription, with no verdict pending: refuse + /// unsupported parameters, apply a priority, then start verifying a renewal's token, or + /// answer at once. A token-less update, or any update on a union-authorized request (which + /// holds no grant to renew), owes one answer so the peer's MAX_REQUEST_UPDATES credit is + /// restored. + /// + /// An error is how the request ends. + async fn apply_subscription_update( + &self, + stream: &mut Stream, + serve: &mut TrackServe, + request_grant: &Option, + renewal: &mut request_update::Renewal, + update: super::request_stream::Update, + ) -> Result<(), Served> { + // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, naming the + // update's own Request ID; later drafts name none. + let answer = self.version != Version::Draft14; + let failed = |err| Served::Track(Err(err)); + // A token renews a token-authorized request. Where tokens decide, one arriving on a + // request admitted without one is refused rather than taken unread, so every token this + // side admits is verified. + let refused = match update.refused { + Some(Error::Decode(err)) => Some((super::token::reason(&err), Error::Decode(err))), + Some(err) => Some(("REQUEST_UPDATE parameters not supported", err)), + None if update.authorization_token.is_some() + && request_grant.is_none() + && self.auth.token_decides(crate::auth::Direction::Publish) => + { + Some(( + "AUTHORIZATION TOKEN on a request admitted without one", + Error::Unsupported, + )) + } + None => None, + }; + if let Some((reason, err)) = refused { + if answer { + self.write_subscribe_error(&mut stream.writer, update.request_id, &err, reason) + .await + .map_err(failed)?; + } + // Either way the update failed, which ends the subscription. + return Err(Served::UpdateFailed(Error::Unsupported)); + } + if let Some(priority) = update.priority { + serve + .set_priority(super::priority::from_wire(priority)) + .map_err(failed)?; + } + if let (Some(token), Some(grant)) = (update.authorization_token, request_grant) { + renewal.verify(grant.verify_renewal(&self.auth, token.0), update.request_id); + return Ok(()); + } + if answer { + self.write_request_ok(&mut stream.writer, update.request_id) + .await + .map_err(failed)?; + } + Ok(()) + } + + /// Answer a REQUEST_UPDATE with REQUEST_OK. + /// + /// Drafts 15 and 16 answer on the control stream, naming the update's own Request ID; + /// later drafts name none. Draft-14 answers nothing and never reaches here. + async fn write_request_ok( + &self, + writer: &mut Writer, + request_id: RequestId, + ) -> Result<(), Error> { + writer.buffer_varint(ietf::RequestOk::ID)?; + writer + .encode(&ietf::RequestOk { + request_id: matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id), + active: None, + }) + .await + } + /// Reject a SUBSCRIBE, ending the request stream. /// /// Takes the whole stream because delivering the error is the other half of the job: @@ -1197,7 +1485,7 @@ where match self.version { Version::Draft14 => { - writer.varint(ietf::SubscribeError::ID).await?; + writer.buffer_varint(ietf::SubscribeError::ID)?; writer .encode(&ietf::SubscribeError { request_id, @@ -1207,7 +1495,7 @@ where .await?; } Version::Draft15 | Version::Draft16 => { - writer.varint(ietf::RequestError::ID).await?; + writer.buffer_varint(ietf::RequestError::ID)?; writer .encode(&ietf::RequestError { request_id: Some(request_id), @@ -1218,7 +1506,7 @@ where .await?; } _ => { - writer.varint(ietf::RequestError::ID).await?; + writer.buffer_varint(ietf::RequestError::ID)?; writer .encode(&ietf::RequestError { request_id: None, @@ -1648,6 +1936,11 @@ where // Every FETCH is held to the grant while its group loads and its response is written. // A standalone one is also checked here, before it reaches the origin; a joining one // was checked when its subscription was. + // + // One joining a subscription its own request token authorized holds no authority of its + // own, so it ends with that subscription: on the token's expiry, revocation, or a + // refused renewal. + let mut lapses_with = None; let (track, start, end, joined, ranking, mut gate) = match fetch_type { FetchType::Standalone { namespace, @@ -1701,13 +1994,19 @@ where | FetchType::AbsoluteJoining { subscriber_request_id, .. } => { - let (end, cache, namespace, ranking) = match self.joined(&mut stream, subscriber_request_id).await? { - Ok(joined) => joined, - Err((err, reason)) => return self.reject_fetch(stream, msg.request_id, &err, reason).await, - }; + let (end, cache, namespace, ranking, token) = + match self.joined(&mut stream, subscriber_request_id).await? { + Ok(joined) => joined, + Err((err, reason)) => return self.reject_fetch(stream, msg.request_id, &err, reason).await, + }; // The cache outlives the subscription, so the fetch holds its own gate rather - // than trusting the subscription's to stop it. - let gate = crate::auth::Gate::new(self.auth.clone(), namespace, crate::auth::Direction::Publish); + // than trusting the subscription's to stop it. One the subscription's request + // token authorized is held to the peer's grant alone, as the subscription is. + let gate = match token { + true => crate::auth::Gate::granted(self.auth.clone(), namespace, crate::auth::Direction::Publish), + false => crate::auth::Gate::new(self.auth.clone(), namespace, crate::auth::Direction::Publish), + }; + lapses_with = token.then_some(subscriber_request_id); let start = match fetch_type { FetchType::RelativeJoining { group_offset, .. } => end.group.saturating_sub(group_offset), FetchType::AbsoluteJoining { group_id, .. } if group_id <= end.group => group_id, @@ -1759,6 +2058,17 @@ where .await; } let until = (end.object > 0).then_some(end.object); + let joins = self.joins.clone(); + let lapsed = |waiter: &kio::Waiter| { + lapses_with.is_some_and(|id| { + joins + .poll(waiter, |joins| match joins.contains_key(&id) { + true => Poll::Pending, + false => Poll::Ready(()), + }) + .is_ready() + }) + }; // The subscriber cancelling is the only other way this ends early, and is owed // nothing. @@ -1770,7 +2080,7 @@ where if super::request_stream::poll_cancel(&mut stream, &mut finished, self.version, &mut cx).is_ready() { return Poll::Ready(None); } - if gate.poll_denied(waiter).is_ready() { + if gate.poll_denied(waiter).is_ready() || lapsed(waiter) { return Poll::Ready(Some(Err(Error::Unauthorized))); } waiter.poll_future(read.as_mut()).map(Some) @@ -1953,7 +2263,7 @@ where let res = { let mut respond = std::pin::pin!(respond); kio::wait(|waiter| { - if gate.poll_denied(waiter).is_ready() { + if gate.poll_denied(waiter).is_ready() || lapsed(waiter) { return Poll::Ready(Err(Error::Unauthorized)); } waiter.poll_future(respond.as_mut()) @@ -1981,7 +2291,7 @@ where &mut self, stream: &mut Stream, subscribe_id: RequestId, - ) -> Result, Error> { + ) -> Result, Error> { // Request streams can arrive out of order. Wait on registration, while bounding // the lifetime of a request whose subscription never arrives or resolves. let joined = { @@ -2023,7 +2333,8 @@ where cache, namespace, ranking, - })) => return Ok(Ok((end, cache, namespace, ranking))), + token, + })) => return Ok(Ok((end, cache, namespace, ranking, token))), Ok(None) => ( match self.version { Version::Draft14 @@ -2426,6 +2737,7 @@ where request_id, track_namespace: path.as_path(), cluster, + authorization_token: None, }) .await?; @@ -4940,7 +5252,7 @@ mod serve_tests { let mut run = std::pin::pin!( h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None,) ); assert_eq!( futures::poll!(run.as_mut()).is_ready(), @@ -4985,7 +5297,7 @@ mod serve_tests { let mut finished = false; let run = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}); + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None); assert!(futures::poll!(std::pin::pin!(run)).is_ready(), "{version}"); } } @@ -5068,10 +5380,11 @@ mod serve_tests { None, ); let mut finished = false; - let mut run = std::pin::pin!( - h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - ); + let mut run = + std::pin::pin!( + h.publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None,) + ); assert!(futures::poll!(run.as_mut()).is_pending()); assert_eq!(h.track.subscription().unwrap().priority, 245); assert_eq!(occurrences(&session.log, &[0x07, 0, 1, 0]), 2); @@ -5099,10 +5412,11 @@ mod serve_tests { ) .ranked(queue); let mut finished = false; - let mut run = std::pin::pin!( - h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - ); + let mut run = + std::pin::pin!( + h.publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None,) + ); // An unfinished group keeps its stream open. let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5139,9 +5453,12 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{served:?}"); + assert!( + matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), + "{served:?}" + ); } /// The request stream's control messages and every data stream's first FIN or reset, @@ -5342,12 +5659,52 @@ mod serve_tests { wire.assert_streams_closed_before_publish_done(); } + /// A track that ends with an Unsupported error (a relay refusing a copy whose metadata + /// changed does) ends its subscription with INTERNAL_ERROR: UPDATE_FAILED is only for an + /// update the publisher could not apply. + #[moq_net_sim::test] + async fn an_unsupported_track_error_is_not_a_failed_update() { + let version = Version::Draft18; + let h = serve(version); + let mut session = ScriptedSession::per_stream(vec![vec![]]); + let mut stream = Stream::open(&mut session, version).await.unwrap(); + let mut serving = TrackServe::new( + h.session.clone(), + h.track.subscribe(None), + RequestId(0), + version, + ServeRange::default(), + None, + ); + h.track.clone().abort(Error::Unsupported).unwrap(); + let mut finished = false; + let served = h + .publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) + .await + .expect("the track's error ends the subscription"); + assert!(matches!(served, Served::Track(Err(Error::Unsupported))), "{served:?}"); + assert_eq!( + served.status(Version::Draft16).0, + ietf::PublishDoneStatus::InternalError + ); + assert_eq!( + Served::UpdateFailed(Error::Unsupported).status(Version::Draft16).0, + ietf::PublishDoneStatus::UpdateFailed + ); + // Draft 14 answers no update, so its PUBLISH_DONE carries the refusal itself. + assert_eq!( + Served::UpdateFailed(Error::Unauthorized).status(Version::Draft14).0, + ietf::PublishDoneStatus::Unauthorized + ); + } + /// Draft-21 §9.5: a failed REQUEST_UPDATE is answered with REQUEST_ERROR, then the /// subscription ends with PUBLISH_DONE(UPDATE_FAILED). /// - /// The update carries an authorization token this publisher cannot verify. It - /// arrives while object 0 of the current group is half written, so the fill stream - /// is stalled inside it and the group stream waits for object 1. + /// The update asks for FORWARD=0, which this publisher does not serve. It arrives + /// while object 0 of the current group is half written, so the fill stream is stalled + /// inside it and the group stream waits for object 1. async fn fail_update_mid_flight(version: Version) -> Wire { let h = serve(version); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5374,8 +5731,8 @@ mod serve_tests { ) .await; - // REQUEST_UPDATE, Request ID 2, one AUTHORIZATION TOKEN (0x03): USE_VALUE, type 0. - request.push(&[0x02, 0, 6, 2, 1, 0x03, 2, 0x03, 0x00]); + // REQUEST_UPDATE, Request ID 2, one FORWARD (0x10) of 0. + request.push(&[0x02, 0, 4, 2, 1, 0x10, 0x00]); moq_net_sim::timeout(std::time::Duration::from_secs(10), serve) .await .expect("the failed update ends the subscription") @@ -5417,8 +5774,8 @@ mod serve_tests { /// A draft 14-16 update framed as the adapter routes it onto its subscription. /// - /// Drafts 15 and 16 carry only SUBSCRIBER_PRIORITY and FORWARD here, since the codec's - /// own encoding always adds a Subscription Filter, which this publisher refuses. + /// Drafts 15 and 16 carry only SUBSCRIBER_PRIORITY and FORWARD here, written by hand so + /// the bytes are pinned. fn legacy_update(version: Version, priority: u8, forward: bool) -> Vec { const UPDATE_ID: u8 = 10; let body = match version { @@ -5429,8 +5786,10 @@ mod serve_tests { subscription_request_id: Some(RequestId(0)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: priority, - forward, + subscriber_priority: Some(priority), + forward: Some(forward), + filter: None, + authorization_token: None, } .encode_msg(&mut Encoder::new(&mut body, version.into()), version) .unwrap(); @@ -5466,7 +5825,7 @@ mod serve_tests { let mut run = std::pin::pin!( h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None,) ); assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); assert_eq!(h.track.subscription().unwrap().priority, 245, "{version}"); @@ -5508,9 +5867,12 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{version}: {served:?}"); + assert!( + matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), + "{version}: {served:?}" + ); // The answer is all that was written: [type, length (2), Request ID, ...]. let written = session.log.writes.lock().unwrap().clone(); match version { @@ -5535,8 +5897,10 @@ mod serve_tests { subscription_request_id: Some(RequestId(0)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 10, - forward: true, + subscriber_priority: Some(10), + forward: Some(true), + filter: None, + authorization_token: None, } .encode_msg(&mut Encoder::new(&mut body, version.into()), version) .unwrap(); @@ -5592,9 +5956,12 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; - assert!(matches!(served, Some(Err(_))), "{version} {why}: {served:?}"); + assert!( + matches!(served, Some(Served::Track(Err(_)) | Served::UpdateFailed(_))), + "{version} {why}: {served:?}" + ); let codes: Vec = h.log.closes().into_iter().map(|(code, _)| code).collect(); assert_eq!(codes, vec![expected.to_code()], "{version} {why}"); } @@ -5617,10 +5984,11 @@ mod serve_tests { const TOKEN: &[u8] = &[0x03, 0x00, 0xAA]; /// Section 8.9: a request whose tokens repeat is rejected MALFORMED_AUTH_TOKEN, which - /// draft-15 moved from 0x10 to 0x4, and the session stays open. A token with another - /// value is not a repeat. + /// draft-15 moved from 0x10 to 0x4. Distinct tokens are legal but refused NOT_SUPPORTED, + /// since authorizing on one would ignore the rest. Either way the session stays open, and + /// a single token is answered as usual. #[moq_net_sim::test] - async fn a_repeated_request_token_rejects_only_the_request() { + async fn a_request_with_repeated_or_several_tokens_is_refused_alone() { for (version, malformed) in [(Version::Draft14, 0x10), (Version::Draft22, 0x4)] { let request = |tokens: &[&[u8]]| { let mut body = track_status("room", true, version); @@ -5632,23 +6000,44 @@ mod serve_tests { let code = refusal(version, ietf::TrackStatus::ID, request(&[TOKEN, TOKEN])).await; assert_eq!(code, malformed, "{version}"); - let h = serve(version); let other = [0x03, 0x00, 0xBB]; - assert_eq!( - answer_code(&h, request(&[TOKEN, &other]), version).await, - None, - "{version}" - ); + let code = refusal(version, ietf::TrackStatus::ID, request(&[TOKEN, &other])).await; + let unsupported = request::to_code(&Error::Unsupported, request::Kind::TrackStatus, version); + assert_eq!(code, unsupported, "{version}: several tokens"); + + // Junk after the tokens still closes the session: a token refusal must not hide it. + for tokens in [[TOKEN, TOKEN], [TOKEN, &other]] { + let mut body = request(&tokens); + body.push(0); + let h = serve(version); + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let closed = h + .publisher + .clone() + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) + .err(); + assert!(matches!(closed, Some(Error::WrongSize)), "{version}: {closed:?}"); + } + + let h = serve(version); + assert_eq!(answer_code(&h, request(&[TOKEN]), version).await, None, "{version}"); } } /// An update whose tokens repeat is answered MALFORMED_AUTH_TOKEN rather than - /// NOT_SUPPORTED, and fails only its subscription. + /// NOT_SUPPORTED, and one with several distinct tokens NOT_SUPPORTED. Either fails only its + /// subscription. #[moq_net_sim::test] - async fn a_repeated_update_token_is_malformed() { - for version in [Version::Draft16, Version::Draft22] { + async fn a_repeated_or_several_update_tokens_are_refused() { + let other: &[u8] = &[0x03, 0x00, 0xBB]; + for (version, tokens, code) in [ + (Version::Draft16, [TOKEN, TOKEN], 0x4), + (Version::Draft22, [TOKEN, TOKEN], 0x4), + (Version::Draft16, [TOKEN, other], 0x3), + (Version::Draft22, [TOKEN, other], 0x3), + ] { let h = serve(version); - let mut session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN, TOKEN])]); + let mut session = ScriptedSession::per_stream(vec![update_with_tokens(version, &tokens)]); let mut stream = Stream::open(&mut session, version).await.unwrap(); let mut serving = TrackServe::new( h.session.clone(), @@ -5659,18 +6048,30 @@ mod serve_tests { None, ); let mut finished = false; - let served = h - .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{version}: {served:?}"); + let mut run = + std::pin::pin!( + h.publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) + ); + let mut served = None; + for _ in 0..50 { + if let std::task::Poll::Ready(done) = futures::poll!(run.as_mut()) { + served = Some(done); + break; + } + settle().await; + } + assert!( + matches!(served, Some(Some(Served::UpdateFailed(Error::Unsupported)))), + "{version}: {served:?}" + ); assert!(h.log.closes().is_empty(), "{version}: the session closed"); let wire = session.log.writes.lock().unwrap().clone(); let mut buf = Decoder::new(&wire, version.into()); assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{version}"); let error = ietf::RequestError::decode(&mut buf, version).unwrap(); - assert_eq!(error.error_code, 0x4, "{version}"); + assert_eq!(error.error_code, code, "{version}"); } } @@ -5710,6 +6111,7 @@ mod serve_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } } @@ -8042,15 +8444,253 @@ mod serve_tests { } } - /// A datagram group is never fetchable: a FETCH for one is refused like a group that - /// does not exist, newest or not, and opens no fetch stream for its payload. + /// A joining FETCH on a subscription its own request token authorized holds no authority + /// of its own: revoking the token ends the subscription, and the fetch with it, even one + /// parked on stream credit after FETCH_OK. #[moq_net_sim::test] - async fn a_standalone_fetch_of_a_datagram_group_is_refused() { - for version in FETCH_DRAFTS { - for newest in [true, false] { - let mut h = serve(version); - publish_pairs(&mut h, 2, None); - let sequence = h.track.append_datagram(timestamp(), b"d".as_slice()).unwrap(); + async fn a_joining_fetch_ends_with_its_token_subscription() { + for version in JOINING_DRAFTS { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"joined-prefix".to_vec()).unwrap(); + settle().await; + + let bi = kio::Producer::new(true); + let credit = kio::Producer::new(false); + let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + // A limit of nothing, so only the token admits the subscription. + auth.authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mut subscription = std::pin::pin!(publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let accepted = async { requests.next().await.expect("a request") }; + let request = match futures::future::select(std::pin::pin!(accepted), subscription.as_mut()).await { + futures::future::Either::Left((request, _)) => request, + futures::future::Either::Right((res, _)) => panic!("{version}: subscription ended: {res:?}"), + }; + let enforced = request.accept(crate::auth::Grant::all()); + let joined = publisher.joins.wait(|joins| match joins.get(&RequestId(REQUEST_ID)) { + Some(Some(_)) => Poll::Ready(()), + _ => Poll::Pending, + }); + match futures::future::select(std::pin::pin!(joined), subscription.as_mut()).await { + futures::future::Either::Left(_) => {} + futures::future::Either::Right((res, _)) => panic!("{version}: subscription ended: {res:?}"), + } + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mark = session.log.writes.lock().unwrap().len(); + let mut fetch = std::pin::pin!(publisher.clone().run_fetch_stream( + stream, + ietf::Fetch { + request_id: FETCH_ID, + subscriber_priority: 128, + group_order: GroupOrder::Ascending, + fetch_type: FetchType::RelativeJoining { + subscriber_request_id: RequestId(REQUEST_ID), + group_offset: 0, + }, + range_filters: false, + fill_timeout: false, + properties_wanted: true, + }, + )); + for _ in 0..100 { + assert!( + futures::poll!(fetch.as_mut()).is_pending(), + "{version}: never waited on credit" + ); + if session.log.writes.lock().unwrap().len() > mark { + break; + } + moq_net_sim::yield_now().await; + } + assert!( + session.log.writes.lock().unwrap().len() > mark, + "{version}: FETCH_OK never went out" + ); + + enforced.revoke(crate::SessionError::Unauthorized, "revoked"); + let _ = moq_net_sim::timeout(std::time::Duration::from_secs(1), subscription.as_mut()) + .await + .unwrap_or_else(|_| panic!("{version}: the revoked subscription kept serving")); + let res = moq_net_sim::timeout(std::time::Duration::from_secs(1), fetch) + .await + .unwrap_or_else(|_| panic!("{version}: the fetch outlived its subscription")); + assert!(matches!(res, Err(Error::Unauthorized)), "{version}: {res:?}"); + drop(credit); + } + } + + /// Where a token subscription is parked when its grant ends. + #[derive(Clone, Copy, Debug)] + enum Parked { + /// Resolving a broadcast whose route never answers. + Resolving, + /// Writing SUBSCRIBE_OK to a peer that grants no flow control. + Acking, + /// Writing the END_OF_TRACK marker without stream credit. + Ending, + } + + /// A token subscription's grant ending ends it at every await, setup and teardown + /// included, not only while it serves: what it holds upstream is released first, and the + /// peer is then told as far as it got, refused before SUBSCRIBE_OK and done after it. + #[moq_net_sim::test] + async fn a_revoked_token_ends_a_subscription_wherever_it_waits() { + for version in ALL_VERSIONS { + for parked in [Parked::Resolving, Parked::Acking, Parked::Ending] { + revoked_while(version, parked).await; + } + } + } + + async fn revoked_while(version: Version, parked: Parked) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let (claim, published) = match parked { + Parked::Resolving => ( + Some(origin.dynamic("room", crate::origin::Route::default()).unwrap()), + None, + ), + Parked::Acking | Parked::Ending => { + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); + match parked { + // One published group, so the subscription parks at the live edge. + Parked::Acking => { + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".to_vec()).unwrap(); + group.finish().unwrap(); + } + // An empty finished track: nothing to serve but the marker. + _ => track.finish().unwrap(), + } + (None, Some((broadcast, track))) + } + }; + settle().await; + + let bi = kio::Producer::new(!matches!(parked, Parked::Acking)); + let credit = kio::Producer::new(false); + let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + // A limit of nothing, so only the token admits the subscription. + auth.authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mut subscription = std::pin::pin!(publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let accepted = async { requests.next().await.expect("a request") }; + let request = match futures::future::select(std::pin::pin!(accepted), subscription.as_mut()).await { + futures::future::Either::Left((request, _)) => request, + futures::future::Either::Right((res, _)) => panic!("{version} {parked:?}: ended unverified: {res:?}"), + }; + let enforced = request.accept(crate::auth::Grant::all()); + + // Drive the subscription to where it parks. + let _route = match &claim { + Some(claim) => { + let requested = std::pin::pin!(claim.requested_broadcast()); + match futures::future::select(requested, subscription.as_mut()).await { + futures::future::Either::Left((route, _)) => Some(route.unwrap()), + futures::future::Either::Right((res, _)) => panic!("{version} {parked:?}: ended: {res:?}"), + } + } + None => None, + }; + for _ in 0..50 { + assert!( + futures::poll!(subscription.as_mut()).is_pending(), + "{version} {parked:?}: ended before its grant did" + ); + settle().await; + } + let subscribed = || { + published + .as_ref() + .is_some_and(|(_, track)| track.subscription().is_some()) + }; + match parked { + Parked::Resolving => {} + Parked::Acking => { + assert!(subscribed(), "{version}: never subscribed upstream"); + assert!( + session.log.writes.lock().unwrap().is_empty(), + "{version}: SUBSCRIBE_OK was not held" + ); + } + Parked::Ending => assert!( + session.log.trail().iter().all(|(stream, _)| *stream == 0), + "{version}: the marker went out without credit" + ), + } + + enforced.revoke(crate::SessionError::Unauthorized, "revoked"); + if matches!(parked, Parked::Acking) { + // The peer cannot read a word yet, but the upstream subscription is already gone. + for _ in 0..10 { + assert!(futures::poll!(subscription.as_mut()).is_pending(), "{version}"); + settle().await; + } + assert!(!subscribed(), "{version}: held upstream until the peer could be told"); + let Ok(mut writable) = bi.write() else { + panic!("request stream gate closed"); + }; + *writable = true; + drop(writable); + } + let _ = moq_net_sim::timeout(std::time::Duration::from_secs(1), subscription.as_mut()) + .await + .unwrap_or_else(|_| panic!("{version} {parked:?}: outlived its grant")); + + let wire = Wire::read(&session.log, version); + let sent: Vec = wire.messages.iter().map(|(id, _)| *id).collect(); + let expected = match (parked, version) { + (Parked::Resolving, Version::Draft14) => vec![ietf::SubscribeError::ID], + (Parked::Resolving, _) => vec![ietf::RequestError::ID], + _ => vec![ietf::SubscribeOk::ID, ietf::PublishDone::ID], + }; + assert_eq!(sent, expected, "{version} {parked:?}"); + drop(credit); + } + + /// A datagram group is never fetchable: a FETCH for one is refused like a group that + /// does not exist, newest or not, and opens no fetch stream for its payload. + #[moq_net_sim::test] + async fn a_standalone_fetch_of_a_datagram_group_is_refused() { + for version in FETCH_DRAFTS { + for newest in [true, false] { + let mut h = serve(version); + publish_pairs(&mut h, 2, None); + let sequence = h.track.append_datagram(timestamp(), b"d".as_slice()).unwrap(); if !newest { h.track.append_group().unwrap().finish().unwrap(); } @@ -8387,6 +9027,1567 @@ mod serve_tests { assert!(!serve(Some(3)).await.flags.has_end, "capped past the last object"); assert!(serve(None).await.flags.has_end, "the whole group"); } + + /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's + /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). + fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) + } + + /// [`serve_with_auth`] over a given session, such as one that finishes the stream after + /// its script. + fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", crate::track::Info::timed()).unwrap(); + + let log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth); + + Serve { + publisher, + session, + log, + track, + _origin: origin, + _broadcast: broadcast, + } + } + + /// A grant to subscribe to everything, lapsing in `secs` (or never), on the publisher's clock. + fn grant_all_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: secs.map(|s| runtime.now().checked_add(Duration::from_secs(s)).unwrap()), + } + } + + /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` + /// falls to its request token. The presented credential is kept alive by the returned + /// [`crate::auth::Token`]. + fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + // The peer lets us publish everything, and we let it read only "other". + auth.granted(0, crate::auth::Grant::all()); + auth.authorize(&crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }); + assert!( + !auth.allows(crate::auth::Direction::Publish, "room"), + "the limit must not cover the request path" + ); + (auth, requests, cred) + } + /// An out-of-band request token, the only kind a session verifies. + /// A request token whose Token Type takes two varint bytes. + fn request_token() -> crate::ietf::token::RequestToken { + crate::ietf::token::RequestToken(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff], + }) + } + + /// One REQUEST_UPDATE carrying a fresh AUTHORIZATION TOKEN, framed as the peer sends it. + async fn subscribe_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(0x40), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// The wire bytes of one REQUEST_OK answering `request_id`: keyed to it on draft-15/16, + /// unkeyed on draft-17+. + async fn request_ok_bytes(version: Version, request_id: RequestId) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id), + active: None, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// The REQUEST_ERROR a refused renewal writes before the subscription ends: keyed to the + /// update's Request ID on draft-15/16, unkeyed on draft-17+, matching + /// `write_subscribe_error(.., &Error::Unauthorized, reason)`. + async fn request_error_bytes(version: Version, request_id: RequestId, reason: &str) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let request_id = matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id); + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id, + error_code: request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version), + reason_phrase: reason.into(), + retry_interval: 0, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A live SUBSCRIBE for `room/video` presenting a request token, with one published + /// group so the subscription parks at the live edge rather than ending. + fn token_subscribe() -> ietf::Subscribe<'static> { + let mut msg = subscribe(Filter::NextObject, None); + msg.request_id = RequestId(REQUEST_ID); + msg.authorization_token = Some(request_token()); + msg + } + + /// A REQUEST_UPDATE whose token the acceptor renews keeps a token-authorized + /// subscription alive past the old grant's expiry: the reader re-verifies the token off + /// the subscribe stream and re-arms the deadline. Proven against the deadline the + /// original grant would otherwise have lapsed at. + #[moq_net_sim::test] + async fn a_request_update_renews_a_token_subscription_past_the_old_expiry() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Drive until the acceptor has answered the initial token and the renewal. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "subscription ended during setup" + ); + if answered.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + + // Let the serve loop apply the renewal it read off the stream. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the + // subscription lives on. + moq_net_sim::advance(Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the renewal did not extend the subscription past the old expiry" + ); + settle().await; + } + } + + /// The pre-draft-17 renewal: a SUBSCRIBE_UPDATE carrying a fresh token renews a + /// token-authorized subscription past the old grant's expiry at draft-14/15/16, where the + /// update rides the control-stream adapter. Draft-14 writes no answer (it defines none, and + /// its control stream would read a 0x05 as ending the subscription); draft-15 and draft-16 + /// (section 9.11) write exactly one REQUEST_OK, keyed to the update's own Request ID so the + /// peer matches it to the REQUEST_UPDATE it sent. The adapter's follow-up routing of the + /// update to the subscription's stream is proven by + /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. + #[moq_net_sim::test] + async fn a_subscribe_update_renewal_is_answered_from_draft_15() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, true).await; + } + } + + /// A refused renewal ends only its subscription at once (PUBLISH_DONE UPDATE_FAILED, or the + /// refusal itself on draft 14), never the session. The update is answered first: nothing on + /// draft-14, a keyed REQUEST_ERROR to the update's Request ID on draft-15/16. + #[moq_net_sim::test] + async fn a_refused_subscribe_update_renewal_ends_the_subscription_from_draft_14() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, false).await; + } + } + + async fn renews(version: Version, accept: bool) { + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(version, auth, subscribe_update_with_token(version).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + + // The renewal is held until the setup writes have settled, so anything written after + // it is released is the renewal's answer. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + let release = release.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + release.notified().await; + if accept { + held.push(renewal.accept(grant_all_expiring(&rt, None))); + } else { + renewal.reject(crate::SessionError::Unauthorized, "no"); + } + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version:?}: ended during setup" + ); + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 1, + "{version:?}: the first token was answered" + ); + let before = h.log.writes.lock().unwrap().len(); + + release.notify_one(); + + if accept { + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version:?}: an accepted renewal keeps serving" + ); + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "{version:?}: the renewal was answered" + ); + // Draft-14 writes nothing; draft-15/16 write exactly one keyed REQUEST_OK to the + // update's own Request ID (0x40), the id the renewing peer waits on. + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + match version { + Version::Draft14 => assert!(written.is_empty(), "{version:?}: a renewal writes no answer"), + _ => assert_eq!( + written, + request_ok_bytes(version, RequestId(0x40)).await, + "{version:?}: one keyed REQUEST_OK to the update's request id" + ), + } + // The re-armed deadline keeps the subscription past the old 60s expiry. + moq_net_sim::advance(Duration::from_secs(120)).await; + let mut ended = false; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(!ended, "{version:?}: an accepted renewal keeps the subscription"); + } else { + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused renewal ends only + // this subscription, promptly and without the old 60s grant having to lapse, with + // PUBLISH_DONE UPDATE_FAILED. The update is answered first: nothing on draft-14, + // exactly one keyed REQUEST_ERROR to the update's Request ID (0x40) on draft-15/16. + // The session stays up. + let mut result = None; + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "{version:?}: the renewal was answered" + ); + assert!( + matches!(result, Some(Err(Error::Session(crate::SessionError::Unauthorized)))), + "{version:?}: a refused renewal ends the subscription at once, got {result:?}" + ); + // Draft 14 has no UPDATE_FAILED and answers no update, so its PUBLISH_DONE + // carries the refusal itself. + let (status, _) = Wire::read(&h.log, version).publish_done.expect("PUBLISH_DONE was sent"); + let expected = match version { + Version::Draft14 => ietf::PublishDoneStatus::Unauthorized, + _ => ietf::PublishDoneStatus::UpdateFailed, + }; + assert_eq!(status, expected.code(version), "{version:?}"); + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + match version { + // draft-14 has no renewal reply: the refusal is signalled only by the subscription + // ending (SUBSCRIBE_DONE UNAUTHORIZED, written by the caller), with no REQUEST_ERROR. + Version::Draft14 => {} + // draft-15/16 answer with one keyed REQUEST_ERROR before the subscription ends. + _ => { + let error = request_error_bytes(version, RequestId(0x40), "no").await; + assert!( + written.starts_with(&error), + "{version:?}: a keyed REQUEST_ERROR is written before the subscription ends" + ); + } + } + assert!(h.log.closes().is_empty(), "{version:?}: the session must stay up"); + } + } + + /// One REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID so several can be + /// told apart on the wire. + async fn subscribe_update_token_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// One token-less REQUEST_UPDATE (a priority or forward change) keyed to `rid`, framed as the + /// peer sends it. It carries no AUTHORIZATION TOKEN, so it starts no verify and is answered at + /// once rather than held. + async fn subscribe_update_bare_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: None, + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two token renewals that arrive while a first renewal's verdict is still pending are each + /// verified and answered, not collapsed. The loop keeps per-update state while a verify is + /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced + /// dropped the middle renewal (0x42) entirely, so its token was never verified. + #[moq_net_sim::test] + async fn two_renewals_buffered_behind_a_pending_verdict_each_get_a_verdict() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = subscribe_update_token_rid(VERSION, 0x40).await; + script.extend(subscribe_update_token_rid(VERSION, 0x42).await); + script.extend(subscribe_update_token_rid(VERSION, 0x44).await); + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Hold the first renewal (0x40) so the next two (0x42, 0x44) are read and buffered while + // its verdict is pending: the exact window the single slot used to collapse. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.unwrap(); + let mut held = vec![first.accept(grant_all_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.unwrap(); + release.notified().await; + held.push(a.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // 0x42 then 0x44: reached only if neither was dropped from the buffer. + loop { + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let 0x42 and 0x44 buffer behind the held 0x40. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "ended during setup"); + settle().await; + } + release.notify_one(); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 { + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 4, + "the initial token and all three renewals must each be verified; the single slot dropped 0x42" + ); + } + + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that + /// broke the negotiated limit: draft-19 section 10.3.1.7 closes the session with + /// TOO_MANY_REQUEST_UPDATES. + #[moq_net_sim::test] + async fn renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it + // is the (limit + 1)th outstanding, the one that must close the session. + let last = 0x40 + 2 * request_update::MAX_REQUEST_UPDATES; + let mut script = Vec::new(); + for rid in (0x40..=last).step_by(2) { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "flooding past the advertised limit must end the request"); + assert!( + h.log + .closes() + .iter() + .any(|close| close.0 == crate::SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + h.log.closes() + ); + } + + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the request + /// keeps running and the session stays up. + #[moq_net_sim::test] + async fn renewals_at_the_advertised_limit_keep_the_request() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // The held renewal plus MAX_REQUEST_UPDATES - 1 queued behind it is exactly the limit. + let last = 0x40 + 2 * (request_update::MAX_REQUEST_UPDATES - 1); + let mut script = Vec::new(); + for rid in (0x40..=last).step_by(2) { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the request must stay up at exactly the advertised limit" + ); + settle().await; + } + assert!( + h.log.closes().is_empty(), + "no session close at the limit: {:?}", + h.log.closes() + ); + } + + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the request nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[moq_net_sim::test] + async fn older_drafts_do_not_strand_a_renewal_flood() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = Vec::new(); + for rid in (0x40..=0x80u64).step_by(2) { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the request" + ); + settle().await; + } + assert!(h.log.closes().is_empty(), "draft-18 flood must not close the session"); + } + + /// Every REQUEST_UPDATE owes one answer, token or not (moq-transport section 10.9), and that + /// answer restores one MAX_REQUEST_UPDATES credit. A peer that only sends token-less priority + /// updates must keep its credit so it can still renew, so each gets a REQUEST_OK and the + /// session stays up well past the advertised limit. + #[moq_net_sim::test] + async fn token_less_updates_are_each_acknowledged() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // More than the advertised limit, none carrying a token: none starts a verify, so none is + // ever outstanding. + let count = request_update::MAX_REQUEST_UPDATES + 4; + let mut script = Vec::new(); + for rid in (0x40..0x40 + 2 * count).step_by(2) { + script.extend(subscribe_update_bare_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "token-less updates must not strand the request" + ); + settle().await; + } + + // Draft-19 answers each with a REQUEST_OK, request id omitted. + let one_ok = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: None, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + let written = h.log.writes.lock().unwrap().clone(); + let acks = written + .windows(one_ok.len()) + .filter(|w| *w == one_ok.as_slice()) + .count(); + assert_eq!( + acks, count as usize, + "each token-less update must get exactly one REQUEST_OK" + ); + assert!( + h.log.closes().is_empty(), + "token-less updates must not close the session" + ); + } + + /// An update that arrives while a renewal's verdict is pending is answered after the + /// renewal, never before it: from draft 17 an answer names no Request ID, so its order is + /// all that ties it to its update. The renewal is refused, so its REQUEST_ERROR is the first + /// answer either way, and the token-less update behind it is never answered ahead of it. + #[moq_net_sim::test] + async fn an_update_behind_a_pending_renewal_is_answered_in_order() { + for version in [Version::Draft16, Version::Draft18] { + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = subscribe_update_token_rid(version, 0x40).await; + script.extend(subscribe_update_bare_rid(version, 0x42).await); + let h = serve_with_auth(version, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let release = Release::default(); + let acceptor = { + let release = release.clone(); + async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + let renewal = requests.next().await.unwrap(); + release.notified().await; + renewal.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Both updates are read while the renewal is held. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}: ended early"); + settle().await; + } + let ok = request_ok_bytes(version, RequestId(0x42)).await; + let held = h.log.writes.lock().unwrap().clone(); + assert!( + !held.windows(ok.len()).any(|window| window == ok.as_slice()), + "{version}: the update behind the pending renewal was answered first" + ); + let before = held.len(); + + release.notify_one(); + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + break; + } + settle().await; + } + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + let error = request_error_bytes(version, RequestId(0x40), "no").await; + assert!( + written.starts_with(&error), + "{version}: the renewal's REQUEST_ERROR is the first answer" + ); + } + } + + /// Before draft-17 a token-less REQUEST_UPDATE is answered keyed to its own request id + /// (draft-15/16), or not at all (draft-14, which defines no response). This pins the keyed and + /// silent branches of the acknowledgement so a wrong key cannot misroute the peer's answer. + #[moq_net_sim::test] + async fn token_less_updates_are_keyed_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + let rids = [0x42u64, 0x44, 0x46]; + let mut script = Vec::new(); + for rid in rids { + script.extend(subscribe_update_bare_rid(version, rid).await); + } + let h = serve_with_auth(version, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version}: token-less updates must not end the request" + ); + settle().await; + } + + // Draft-14 answers nothing; draft-15/16 answer each update keyed to its own request id. + let written = h.log.writes.lock().unwrap().clone(); + if !matches!(version, Version::Draft14) { + for rid in rids { + let one = request_ok_bytes(version, RequestId(rid)).await; + let n = written.windows(one.len()).filter(|w| *w == one.as_slice()).count(); + assert_eq!(n, 1, "{version}: update {rid:#x} gets exactly one keyed REQUEST_OK"); + } + } + assert!( + h.log.closes().is_empty(), + "{version}: token-less updates must not close the session" + ); + } + } + + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one request through PUBLISH_DONE, never the session. + #[moq_net_sim::test] + async fn an_older_draft_flood_past_the_guard_ends_the_request() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let last = 0x40 + 2 * (request_update::UNNEGOTIATED_GUARD as u64 + 1); + let mut script = Vec::new(); + for rid in (0x40..=last).step_by(2) { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a flood past the guard must end the request"); + assert!(h.log.closes().is_empty(), "the guard ends the request, not the session"); + } + + /// A peer that ends the subscription while a renewal is still being verified ends it here + /// too: with a grant that never expires and an acceptor that never answers the renewal, + /// only the stream closing can end the request, and it must. + #[moq_net_sim::test] + async fn a_closed_subscription_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let session = ScriptedSession::per_stream_eof(vec![subscribe_update_with_token(VERSION).await]); + let h = serve_on(VERSION, auth, session); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(Ordering::Relaxed) >= 1, + "the subscription was token-authorized" + ); + assert!( + ended, + "closing the stream ends the subscription despite the pending renewal" + ); + } + + /// One UNSUBSCRIBE keyed to the subscription, the draft-14/15/16 cancellation message. + async fn unsubscribe_bytes(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::Unsubscribe::ID).await.unwrap(); + writer + .encode(&ietf::Unsubscribe { + request_id: RequestId(REQUEST_ID), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Before draft-17 a cancellation is an UNSUBSCRIBE message, not a FIN. It must end the + /// request even while a renewal verdict is pending: with a non-expiring grant and an + /// acceptor that never answers the renewal, the UNSUBSCRIBE is the only thing that can. + #[moq_net_sim::test] + async fn a_pending_renewal_ends_on_an_unsubscribe_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + // The stream carries the renewal then the UNSUBSCRIBE and never FINs, so only the + // message can end the request. + let script = [ + subscribe_update_with_token(version).await, + unsubscribe_bytes(version).await, + ] + .concat(); + let h = serve_with_auth(version, auth, script); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + let _never_answered = requests.next().await.expect("a renewal"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + ended, + "{version:?}: an UNSUBSCRIBE ends the subscription despite the pending renewal" + ); + } + } + + /// A REQUEST_UPDATE the acceptor refuses ends only that subscription, at once, through the + /// PUBLISH_DONE UPDATE_FAILED path (drafts 16 section 9.11.1 / 18 section 10.9.1). The old + /// grant does not keep it alive to its 60s expiry, and the session stays up. + #[moq_net_sim::test] + async fn a_refused_request_update_ends_the_subscription() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + // The first grant lasts 60s; the renewal is refused. + let first = requests.next().await.unwrap(); + let _issued = first.accept(grant_all_expiring(&rt, Some(60))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + renewal.reject(crate::SessionError::Unauthorized, "no"); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // No time is advanced: the refusal must end the subscription on its own, not by letting + // the 60s grant lapse. + let mut result = None; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + assert!( + matches!(result, Some(Err(Error::Session(crate::SessionError::Unauthorized)))), + "a refused renewal ends the subscription at once, got {result:?}" + ); + let (status, _) = Wire::read(&h.log, VERSION).publish_done.expect("PUBLISH_DONE was sent"); + assert_eq!( + status, + ietf::PublishDoneStatus::UpdateFailed.code(VERSION), + "UPDATE_FAILED" + ); + // Draft-17+ answers the refused update with exactly one unkeyed REQUEST_ERROR before the + // subscription ends. + let written = h.log.writes.lock().unwrap().clone(); + let error = request_error_bytes(VERSION, RequestId(0), "no").await; + assert_eq!( + written.windows(error.len()).filter(|w| *w == error.as_slice()).count(), + 1, + "exactly one unkeyed REQUEST_ERROR before the subscription ends" + ); + assert!( + h.log.closes().is_empty(), + "the session must stay up: {:?}", + h.log.closes() + ); + } + + /// A slow acceptor answering a renewal must not stall serving nor let the request outlive + /// its grant: the renewal verify is raced against the old deadline, which still fires. + #[moq_net_sim::test] + async fn a_slow_renewal_does_not_stall_serving() { + const VERSION: Version = Version::Draft18; + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s. + let first = requests.next().await.expect("a request"); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + popped.fetch_add(1, Ordering::Relaxed); + // The renewal is popped but never answered: a slow or hung acceptor. + let _slow = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let the loop read the REQUEST_UPDATE and the acceptor pop both requests. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended during setup" + ); + if popped.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(popped.load(Ordering::Relaxed), 2, "the acceptor never saw the renewal"); + + // The acceptor is stuck. Past the old 60s expiry the deadline must still fire and end + // the request UNAUTHORIZED, rather than the pending renewal stalling serving. + moq_net_sim::advance(Duration::from_secs(120)).await; + let mut ended = None; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + ended = Some(res); + break; + } + settle().await; + } + let res = ended.expect("serving must end at the old deadline, not stall on the slow renewal"); + assert!(matches!(res, Err(Error::Unauthorized)), "{res:?}"); + } + + /// Publisher / SUBSCRIBE side: on a session without the AUTH extension the union is + /// `None` forever. A token-bearing SUBSCRIBE the limit does not cover is verified by the + /// application, not admitted by the permissive union. + #[moq_net_sim::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + auth.authorize(&crate::auth::Grant::default()); + + let h = serve_with_auth(VERSION, auth, Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended early" + ); + if consulted.load(Ordering::Relaxed) >= 1 { + break; + } + settle().await; + } + assert!( + consulted.load(Ordering::Relaxed) >= 1, + "the token must be verified by the acceptor, not admitted by the permissive default" + ); + } + + /// A token-bearing SUBSCRIBE being served on a no-AUTH session whose acceptor answers every + /// token with `grant`, after `limit` (if any) narrowed what the peer may do. + type Serving = std::pin::Pin>>>; + + async fn serve_token_subscribe( + grant: crate::auth::Grant, + limit: Option, + ) -> (crate::auth::Handle, Serve, Serving) { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + if let Some(limit) = &limit { + auth.authorize(limit); + } + let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let serving = h.publisher.clone().run_subscribe_stream(stream, token_subscribe()); + let serving: Serving = Box::pin(async move { + let acceptor = async move { + let mut held = Vec::new(); + while let Some(request) = requests.next().await { + held.push(request.accept(grant.clone())); + } + std::future::pending::>().await + }; + let acceptor = std::pin::pin!(acceptor); + let serving = std::pin::pin!(serving); + match futures::future::select(acceptor, serving).await { + futures::future::Either::Left((res, _)) | futures::future::Either::Right((res, _)) => res, + } + }); + (auth, h, serving) + } + + /// Whether `serving` is still running after the acceptor and the serve loop have had their + /// turns. + async fn still_serving(serving: &mut Serving) -> bool { + for _ in 0..300 { + if futures::poll!(serving.as_mut()).is_ready() { + return false; + } + settle().await; + } + true + } + + /// A subscriber's token grant is checked on its `subscribe` patterns: a write-only grant + /// does not admit a SUBSCRIBE, a read-only one does. + #[moq_net_sim::test] + async fn a_subscribe_token_needs_a_subscribe_grant() { + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + let nothing = Some(crate::auth::Grant::default()); + let (_auth, _h, mut serving) = serve_token_subscribe(read_only, nothing.clone()).await; + assert!(still_serving(&mut serving).await, "a read grant admits it"); + let (_auth, _h, mut serving) = serve_token_subscribe(write_only, nothing).await; + assert!(!still_serving(&mut serving).await, "a write grant refuses it"); + } + + /// A request token stands in for the limit this side set on the peer, so a SUBSCRIBE the + /// limit refuses is admitted on the token and a later narrowing leaves it standing. + #[moq_net_sim::test] + async fn a_request_token_stands_in_for_the_local_limit() { + let nothing = Some(crate::auth::Grant::default()); + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), nothing).await; + assert!( + still_serving(&mut serving).await, + "a limit of nothing defers to the token" + ); + auth.authorize(&crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }); + assert!( + still_serving(&mut serving).await, + "narrowing the limit leaves a token-authorized subscription" + ); + } + + /// What the peer's AUTH_OK grants us never authorizes what the peer asks of us: with a + /// covering peer grant and no limit, the application still verifies the SUBSCRIBE's token, + /// and its refusal refuses the subscription. + #[moq_net_sim::test] + async fn a_covering_peer_grant_never_skips_the_verifier() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(true); + let mut requests = auth.requests().unwrap(); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted(0, crate::auth::Grant::all()); + let h = serve_with_auth(VERSION, auth, Vec::new()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let consulted = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, Ordering::Relaxed); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert_eq!(consulted.load(Ordering::Relaxed), 1, "the verifier ran"); + assert!(ended, "the refused subscription ends"); + } + + /// Every token is verified, even on a SUBSCRIBE the limit already covers: the verifier + /// refusing it refuses the subscription, and one it grants stands outside the limit, so + /// narrowing the limit away from it leaves the subscription its token admitted. + #[moq_net_sim::test] + async fn a_covering_limit_still_verifies_the_token() { + let limit = |subtree: &str| crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree(subtree).unwrap().into(), + expires: None, + }; + let (_auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::default(), Some(limit("room"))).await; + assert!( + !still_serving(&mut serving).await, + "the verifier refused the token the limit would have let through" + ); + + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), Some(limit("room"))).await; + assert!(still_serving(&mut serving).await, "the verified token admits it"); + auth.authorize(&limit("other")); + assert!( + still_serving(&mut serving).await, + "narrowing the limit leaves the token-verified subscription" + ); + } + + /// Where tokens decide, a token arriving on an update for a subscription admitted without + /// one is refused NOT_SUPPORTED rather than taken unread, failing only that subscription. + #[moq_net_sim::test] + async fn a_token_on_a_subscription_admitted_without_one_is_refused() { + for version in [Version::Draft16, Version::Draft22] { + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::all()); + let h = serve_with_auth(version, auth, Vec::new()); + let mut session = ScriptedSession::per_stream(vec![update_with_tokens(version, &[TOKEN])]); + let mut stream = Stream::open(&mut session, version).await.unwrap(); + let mut serving = TrackServe::new( + h.session.clone(), + h.track.subscribe(None), + RequestId(0), + version, + ServeRange::default(), + None, + ); + let mut finished = false; + let mut run = + std::pin::pin!( + h.publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) + ); + let mut served = None; + for _ in 0..50 { + if let std::task::Poll::Ready(done) = futures::poll!(run.as_mut()) { + served = Some(done); + break; + } + settle().await; + } + assert!( + matches!(served, Some(Some(Served::UpdateFailed(Error::Unsupported)))), + "{version}: {served:?}" + ); + assert!(h.log.closes().is_empty(), "{version}: the session closed"); + + let wire = session.log.writes.lock().unwrap().clone(); + let mut buf = Decoder::new(&wire, version.into()); + assert_eq!(buf.varint().unwrap(), ietf::RequestError::ID, "{version}"); + let error = ietf::RequestError::decode(&mut buf, version).unwrap(); + assert_eq!(error.error_code, 0x3, "{version}"); + } + } + + /// With no verifier, a limited session refuses a token as unsupported rather than + /// unauthorized, even one its limit covers, and a session never limited ignores the token. + #[moq_net_sim::test] + async fn a_token_without_a_verifier_is_unsupported() { + const VERSION: Version = Version::Draft18; + for limit in [crate::auth::Grant::default(), crate::auth::Grant::all()] { + a_token_without_a_verifier_is_refused(VERSION, limit).await; + } + + let unlimited = crate::auth::Handle::new(false); + let h = serve_with_auth(VERSION, unlimited, Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + settle().await; + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + for _ in 0..50 { + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "an unlimited session serves it" + ); + settle().await; + } + } + + /// Serve a token SUBSCRIBE on a session limited to `limit` with no verifier, and check it + /// is refused NOT_SUPPORTED. + async fn a_token_without_a_verifier_is_refused(version: Version, limit: crate::auth::Grant) { + let auth = crate::auth::Handle::new(false); + auth.authorize(&limit); + let h = serve_with_auth(version, auth, Vec::new()); + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut res = None; + for _ in 0..50 { + if let std::task::Poll::Ready(done) = futures::poll!(serving.as_mut()) { + res = Some(done); + break; + } + settle().await; + } + assert!(matches!(res, Some(Ok(()))), "refused, not failed or served: {res:?}"); + let refusal = { + let log = Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id: None, + error_code: request::to_code(&Error::Unsupported, request::Kind::Subscribe, version), + reason_phrase: Error::Unsupported.to_string().into(), + retry_interval: 0, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + assert_eq!(occurrences(&h.log, &refusal), 1, "refused NOT_SUPPORTED"); + } + + /// A token waiting on the application holds the subscription's slot. Withdrawn while still + /// queued it gives the slot back at once; once the application took it, the slot stays + /// counted until the application answers, and that late answer authorizes nothing. + #[moq_net_sim::test] + async fn a_stalled_verification_holds_the_subscription_slot() { + const VERSION: Version = Version::Draft18; + + // Withdrawn before the application looked. + let auth = crate::auth::Handle::new(false); + let _requests = auth.requests().unwrap(); + let mut h = serve_with_auth(VERSION, auth, Vec::new()); + h.publisher.subscriptions = crate::session::Slots::new(1); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + { + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + for _ in 0..10 { + assert!(futures::poll!(serving.as_mut()).is_pending(), "verification is pending"); + settle().await; + } + assert!( + h.publisher.subscriptions.acquire().is_err(), + "the pending token holds the slot" + ); + } + assert!( + h.publisher.subscriptions.acquire().is_ok(), + "a withdrawn queued token gives the slot back" + ); + + // Withdrawn after the application took it. + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + let mut h = serve_with_auth(VERSION, auth, Vec::new()); + h.publisher.subscriptions = crate::session::Slots::new(1); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let taken = { + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut taken = None; + for _ in 0..10 { + assert!(futures::poll!(serving.as_mut()).is_pending(), "verification is pending"); + if taken.is_none() + && let Poll::Ready(Some(event)) = requests.poll_next(&kio::Waiter::noop()) + { + taken = Some(event); + } + settle().await; + } + taken.expect("the application took the token") + }; + assert!( + h.publisher.subscriptions.acquire().is_err(), + "a token the application holds keeps its slot after the request ends" + ); + let late = taken.accept(crate::auth::Grant::all()); + assert!( + h.publisher.subscriptions.acquire().is_ok(), + "answering releases the slot" + ); + let ended = late.closed().await; + assert!( + matches!(ended, Error::Cancel), + "the late answer lands nowhere: {ended:?}" + ); + } + + /// What the peer granted us bounds what we send, so a request token never admits a + /// SUBSCRIBE outside it, and the application is not even asked. + #[moq_net_sim::test] + async fn a_request_token_cannot_exceed_the_peers_grant() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(true); + let mut requests = auth.requests().unwrap(); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + auth.authorize(&crate::auth::Grant::default()); + let h = serve_with_auth(VERSION, auth, Vec::new()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let res = h + .publisher + .clone() + .run_subscribe_stream(stream, token_subscribe()) + .await; + assert!(res.is_ok(), "refused, not failed: {res:?}"); + assert!( + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "the token must not reach the application" + ); + } + + /// Holds an acceptor until the test releases it. + #[derive(Clone, Default)] + struct Release(kio::Shared); + + impl Release { + fn notify_one(&self) { + *self.0.lock() = true; + } + + async fn notified(&self) { + kio::wait(|waiter| { + self.0 + .poll(waiter, |released| match **released { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + }) + .await + } + } } #[cfg(test)] @@ -9738,8 +11939,10 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), + authorization_token: None, }, ) .await; @@ -9890,10 +12093,12 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: Some(cluster::HopPath::new( crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), )), cost: Some(0), + authorization_token: None, }, ) .await; @@ -10030,8 +12235,10 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), + authorization_token: None, }, ) .await; @@ -10245,6 +12452,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }, ) .await @@ -10597,6 +12805,7 @@ mod range_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } } diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index cce5dca693..c277431314 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -38,6 +38,35 @@ pub(super) fn poll_cancel( Poll::Pending } +/// Watch for the requester ending a request it has not been answered on yet, without consuming +/// what it sent meanwhile: a waiting message (an update, say) ends the watch on the reader and +/// is read once the request is answered. A withdrawal queued behind an update is therefore seen +/// only then; until the verifier answers, the requester holds nothing but its own slot. +pub(super) fn poll_withdrawn( + stream: &mut Stream, + version: Version, + cx: &mut Context<'_>, +) -> Poll<()> { + // Drafts 14-16 deliver the terminal message (UNSUBSCRIBE, PUBLISH_NAMESPACE_DONE) ahead of + // the FIN, so a waiting message that is not an update already withdraws the request. Their + // virtual writers are closed while the control request lives, so only the reader counts. + if matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + return match stream.reader.poll_varint_peek(cx) { + Poll::Ready(Ok(id)) if id == super::SubscribeUpdate::ID => Poll::Pending, + Poll::Ready(_) => Poll::Ready(()), + Poll::Pending => Poll::Pending, + }; + } + if stream.writer.poll_closed(cx).is_ready() { + return Poll::Ready(()); + } + match stream.reader.poll_has_more(cx) { + Poll::Ready(Ok(false)) if fin_cancels(version) => Poll::Ready(()), + Poll::Ready(Err(_)) => Poll::Ready(()), + _ => Poll::Pending, + } +} + /// Poll a draft 14-16 virtual request stream until the requester ends the request. /// /// An update modifies the request rather than ending it, so it is skipped. Any other @@ -78,15 +107,30 @@ impl Decode for FollowUp { } } +/// One whole `[type][length][body]` message off a request stream, decoded at once so a read +/// dropped part-way consumes nothing. +#[derive(Debug)] +pub(super) struct Framed(pub(super) u64, pub(super) super::Body); + +impl Decode for Framed { + fn decode(r: &mut Decoder<'_>, version: Version) -> Result { + let id = r.varint()?; + Ok(Self(id, super::Body::decode(r, version)?)) + } +} + /// A framed subscription update, preserving omitted preferences. #[derive(Debug)] pub(super) struct Update { /// The first Request ID: the update's own on drafts 14-16, which their answer names. pub(super) request_id: super::RequestId, pub(super) priority: Option, - /// Why the update is refused, which fails the subscription: [`Error::Unsupported`], or a - /// [`DecodeError::MalformedAuthToken`] for tokens that repeat. + /// Why the update is refused, which fails the subscription: [`Error::Unsupported`], a + /// [`DecodeError::MalformedAuthToken`] for tokens that repeat, or a + /// [`DecodeError::MultipleAuthTokens`] for more than one token. pub(super) refused: Option, + /// The AUTHORIZATION TOKEN renewing the request's credential. + pub(super) authorization_token: Option, } impl Update { @@ -118,10 +162,12 @@ impl Update { if !data.is_empty() { return Err(DecodeError::InvalidValue); } + let (refused, authorization_token) = Self::refused(tokens, !forward); return Ok(Self { request_id, priority: Some(priority), - refused: Self::refused(&tokens, !forward), + refused, + authorization_token, }); } // The request it updates, which the adapter already routed by. @@ -156,26 +202,31 @@ impl Update { || fill.is_some() || object_timeout.is_some() || subgroup_timeout.is_some() - || !token.is_empty() || !subgroup_filter.is_empty() || !object_filter.is_empty() || !priority_filter.is_empty() || !property_filter.is_empty() || !track_filter.is_empty() || new_group.is_some(); + let (refused, authorization_token) = Self::refused(token, unsupported); Ok(Self { request_id, priority, - refused: Self::refused(&token, unsupported), + refused, + authorization_token, }) } - /// Section 8.9 rejects an update whose tokens repeat, which says more than that we do not - /// support it. - fn refused(tokens: &[super::token::RequestToken], unsupported: bool) -> Option { - match super::token::unique(tokens) { - Err(err) => Some(err.into()), - Ok(()) => unsupported.then_some(Error::Unsupported), + /// Why an update is refused, if it is, and the token it carries. A token refusal comes + /// first: section 8.9 rejects an update whose tokens repeat, which says more than that we + /// do not support it. + fn refused( + tokens: Vec, + unsupported: bool, + ) -> (Option, Option) { + match super::token::single(tokens) { + Err(err) => (Some(err.into()), None), + Ok(token) => (unsupported.then_some(Error::Unsupported), token), } } } diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs new file mode 100644 index 0000000000..256cdbc33a --- /dev/null +++ b/rs/moq-net/src/ietf/request_update.rs @@ -0,0 +1,186 @@ +//! The MAX_REQUEST_UPDATES Setup Option (draft-ietf-moq-transport-19 section 10.3.1.7). +//! +//! A request stream (SUBSCRIBE, PUBLISH_NAMESPACE, ...) can be refreshed in place with +//! REQUEST_UPDATE messages. Each is outstanding from when the peer sends it until its +//! REQUEST_OK or REQUEST_ERROR, and MAX_REQUEST_UPDATES caps how many a peer may leave +//! outstanding on one stream at once. We verify a request token asynchronously, so a slow +//! acceptor holds one renewal unanswered while more pile up behind it; this bound is what +//! stops that queue from growing without limit. +//! +//! The option is draft-19+ (Option Type 0x08). Advertising it lets a conforming peer +//! self-limit, so a peer that exceeds the advertised value broke a negotiated rule, which +//! the draft answers with a session close ([`SessionError::TooManyRequestUpdates`]). Drafts +//! below 19 carry no such option, so a peer there agreed to no ceiling: the receiver keeps +//! a generous local guard that ends only the one request rather than fault a peer at +//! session scope for a limit it never saw. +//! +//! [`SessionError::TooManyRequestUpdates`]: crate::SessionError::TooManyRequestUpdates + +use std::{collections::VecDeque, task::Poll}; + +use super::{RequestId, Version}; +use crate::{Error, SessionError, auth}; + +/// MAX_REQUEST_UPDATES Setup Option (Option Type 0x08). Even, so the value is a bare varint. +pub const OPTION: u64 = 0x08; + +/// The credit we advertise and enforce on drafts that define the option: the most +/// unacknowledged REQUEST_UPDATEs we accept on one request stream, counting the one being +/// verified. One constant drives both the advertisement and the enforcement so the value we +/// promise and the value we hold a peer to cannot drift. +pub const MAX_REQUEST_UPDATES: u64 = 16; + +/// Local resource guard on drafts without the option. Generous relative to the negotiated +/// credit, since those drafts negotiate no limit and a conforming peer must never reach it, but +/// still bounded: a control message carries up to a 16-bit length, so this caps the buffered +/// bytes per request stream rather than letting a flood grow without end. Exceeding it ends the +/// one request, never the session. +pub const UNNEGOTIATED_GUARD: usize = 64; + +/// Whether this version defines MAX_REQUEST_UPDATES. Draft-19 section 10.3.1.7 added it; the +/// drafts below carry no such Setup Option. +pub fn supported(version: Version) -> bool { + !matches!( + version, + Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 + ) +} + +/// A request's in-band renewal: the verdict on the token being verified, and the updates that +/// arrived behind it. +/// +/// From draft 17 an answer names no Request ID, so updates are answered in arrival order: one +/// behind a pending verdict waits here until the verdict ahead of it resolves. The queue is +/// bounded by MAX_REQUEST_UPDATES, counting the one being verified. +pub(super) struct Renewal { + pending: Option<(auth::RequestVerdict, RequestId)>, + queued: VecDeque, +} + +impl Default for Renewal { + fn default() -> Self { + Self { + pending: None, + queued: VecDeque::new(), + } + } +} + +/// An update past the bound on outstanding updates. +pub(super) enum Overflow { + /// Past the MAX_REQUEST_UPDATES we advertised (draft 19+): the session closes. + Session, + /// Past the local guard on a draft without the option: only the request ends. + Request, +} + +impl Renewal { + /// Whether a verdict is pending, so a new update must wait behind it. + pub fn pending(&self) -> bool { + self.pending.is_some() + } + + /// Wait on `verdict`, the renewal the update with `request_id` carried. + pub fn verify(&mut self, verdict: auth::RequestVerdict, request_id: RequestId) { + debug_assert!(self.pending.is_none(), "one renewal is verified at a time"); + self.pending = Some((verdict, request_id)); + } + + /// Queue `update` behind the pending verdict, counting it against the bound. + pub fn queue(&mut self, update: T, version: Version) -> Result<(), Overflow> { + // Outstanding counts the one being verified plus those already queued. + let outstanding = self.queued.len() as u64 + 1; + match supported(version) { + // We advertised the credit, so a peer with that many outstanding sending another + // broke the negotiated limit (draft-19 section 10.3.1.7). + true if outstanding >= MAX_REQUEST_UPDATES => return Err(Overflow::Session), + false if self.queued.len() >= UNNEGOTIATED_GUARD => return Err(Overflow::Request), + _ => {} + } + self.queued.push_back(update); + Ok(()) + } + + /// The next queued update, once no verdict is pending. + pub fn next(&mut self) -> Option { + match self.pending { + Some(_) => None, + None => self.queued.pop_front(), + } + } + + /// `Ready` with the pending verdict once it resolves. + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll { + let Some((verdict, _)) = &self.pending else { + return Poll::Pending; + }; + let grant = std::task::ready!(verdict.poll_grant(waiter)); + let (verdict, request_id) = self.pending.take().expect("a pending renewal"); + Poll::Ready(Verdict { + verdict, + grant, + request_id, + }) + } +} + +/// A renewal's resolved verdict. +pub(super) struct Verdict { + /// What renews the request grant, if the grant covers it. + pub verdict: auth::RequestVerdict, + /// The acceptor's grant, or its refusal. + pub grant: Result, + /// The update that carried the renewal, which the answer names before draft 17. + pub request_id: RequestId, +} + +/// Close `session` for a peer past the MAX_REQUEST_UPDATES we advertised, returning the error +/// that ends the request. +pub(super) fn too_many(session: &mut S) -> Error { + let err = SessionError::TooManyRequestUpdates; + session.close(super::error::to_session_code(&err), "too many request updates"); + Error::Session(err) +} + +/// Advertise our MAX_REQUEST_UPDATES credit, on versions that define the option. +pub fn into_setup(params: &mut super::Parameters, version: Version) { + if supported(version) { + params.set_varint(super::ParameterVarInt::MaxRequestUpdates, MAX_REQUEST_UPDATES); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The option rides SETUP only on the drafts that define it; an older peer sees nothing, + /// which is what keeps the bound a negotiated contract rather than a surprise. + #[test] + fn advertised_only_on_draft_19_plus() { + for version in [Version::Draft19, Version::Draft20, Version::Draft21, Version::Draft22] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + Some(MAX_REQUEST_UPDATES), + "{version} must advertise MAX_REQUEST_UPDATES" + ); + } + + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + None, + "{version} has no MAX_REQUEST_UPDATES option to advertise" + ); + } + } +} diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e7c5eb117a..997fdeeb27 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -10,7 +10,7 @@ use crate::{ use super::{ Control, Message, Publisher, Subscriber, Version, active_count, adapter::ControlStreamAdapter, - auth, cluster, hidden, peer, solicit, + auth, cluster, hidden, peer, request_update, solicit, subscriber::{is_protocol_violation, subscribe_prefixes}, }; @@ -778,6 +778,7 @@ async fn run_setup( auth::into_setup(&mut parameters, version); } active_count::into_setup(&mut parameters, version); + request_update::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; @@ -2531,6 +2532,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } .encode(&mut encoder, VERSION) .unwrap(); @@ -2838,6 +2840,7 @@ mod tests { request_id: RequestId(1), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }) .await .unwrap(); @@ -2947,6 +2950,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let mut body = Vec::new(); let mut w = crate::coding::Encoder::new(&mut body, version.into()); diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 6b3f658489..8ddf6c439c 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -8,6 +8,8 @@ use crate::{ ietf::{Fill, Filter, GroupOrder, Location, Opaque, Parameters, Properties, RequestId}, }; +use super::token::RequestToken; + use super::Message; use super::namespace::{decode_namespace, encode_namespace}; @@ -35,6 +37,9 @@ pub struct Subscribe<'a> { /// MAX_FILTER_RANGES, so the request is refused rather than served unfiltered. /// Never encoded; we send no range filters. pub range_filters: bool, + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. At most one is + /// accepted: decoding refuses a repeated or second token. + pub authorization_token: Option, } /// NEW_GROUP_REQUEST (0x32) arrived in draft-16. @@ -69,7 +74,8 @@ impl Message for Subscribe<'_> { let forward = r.bool()?; let filter = Filter::decode(r, version)?; - super::token::unique(&Parameters::skip_request(r)?)?; + let tokens = Parameters::skip_request(r)?; + let authorization_token = super::token::single(tokens)?; Ok(Self { request_id, @@ -82,12 +88,12 @@ impl Message for Subscribe<'_> { properties_wanted: true, forward, range_filters: false, + authorization_token, }) } _ => { - // The token decodes but grants nothing: the session's grant is what authorizes the request. - // NEW_GROUP_REQUEST is ignored too, as the draft lets a publisher without - // dynamic groups do. + // NEW_GROUP_REQUEST is ignored, as the draft lets a publisher without dynamic + // groups do. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, 0x03 => authorization_token: Vec, @@ -107,7 +113,7 @@ impl Message for Subscribe<'_> { 0x32 => _new_group_request: Option where has_new_group_request(version), 0x35 => include_properties: Option where Filter::is_draft20(version), ); - super::token::unique(&authorization_token)?; + let authorization_token = super::token::single(authorization_token)?; let range_filters = [ subgroup_filter, @@ -143,6 +149,7 @@ impl Message for Subscribe<'_> { // Absent means 1. forward: forward.unwrap_or(true), range_filters, + authorization_token, }) } } @@ -163,7 +170,9 @@ impl Message for Subscribe<'_> { w.bool(self.forward); self.filter.encode(w, version)?; - w.u8(0); // no parameters + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + ); } _ => { // FILL_PARAMETERS arrived in draft-20. Sending it to an older peer would be an @@ -177,6 +186,7 @@ impl Message for Subscribe<'_> { let include_properties = (!self.properties_wanted && Filter::is_draft20(version)).then_some(false); encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, 0x21 => self.filter, @@ -371,10 +381,20 @@ impl Message for Unsubscribe { pub struct SubscribeUpdate { pub request_id: RequestId, pub subscription_request_id: Option, + /// Draft-14's fixed start, which a peer must not see decrease; unused on later drafts. pub start_location: Location, + /// Draft-14's fixed end group (0 for open-ended); unused on later drafts. pub end_group: u64, - pub subscriber_priority: u8, - pub forward: bool, + /// `None` leaves the priority as it is. Draft-14 cannot omit it, so `None` sends the + /// default there. + pub subscriber_priority: Option, + /// `None` leaves forwarding as it is, as for the priority. + pub forward: Option, + /// The new LOCATION_FILTER (draft-15+); `None` keeps the subscription's own range. + pub filter: Option, + /// The `AUTHORIZATION TOKEN` (0x03) renewing the request's credential, if any. At + /// most one is accepted: decoding refuses a repeated or second token. + pub authorization_token: Option, } impl Message for SubscribeUpdate { @@ -389,9 +409,11 @@ impl Message for SubscribeUpdate { .encode(w, version)?; self.start_location.encode(w, version)?; w.varint(self.end_group)?; - w.u8(self.subscriber_priority); - w.bool(self.forward); - w.u8(0); // no parameters + w.u8(self.subscriber_priority.unwrap_or(128)); + w.bool(self.forward.unwrap_or(true)); + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + ); } Version::Draft15 | Version::Draft16 => { self.request_id.encode(w, version)?; @@ -399,9 +421,10 @@ impl Message for SubscribeUpdate { .expect("subscription_request_id required for draft15-16") .encode(w, version)?; encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter, ); } _ => { @@ -415,9 +438,10 @@ impl Message for SubscribeUpdate { w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter, ); } } @@ -432,9 +456,10 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); let start_location = Location::decode(r, version)?; let end_group = r.varint()?; - let subscriber_priority = r.u8()?; - let forward = r.bool()?; - Parameters::skip_request(r)?; + let subscriber_priority = Some(r.u8()?); + let forward = Some(r.bool()?); + let tokens = Parameters::skip_request(r)?; + let authorization_token = super::token::single(tokens)?; Ok(Self { request_id, @@ -443,6 +468,8 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + filter: None, + authorization_token, }) } Version::Draft15 | Version::Draft16 => { @@ -450,16 +477,13 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x32 => _new_group_request: Option where has_new_group_request(version), ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id, @@ -467,6 +491,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: super::token::single(authorization_token)?, }) } _ => { @@ -479,11 +505,11 @@ impl Message for SubscribeUpdate { // consumed so a legal update does not fail the session. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, 0x06 => _subgroup_delivery_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x23 => _fill: Option where Filter::is_draft20(version), 0x25 => _subgroup_filter: Vec where has_range_filters(version), 0x26 => _object_id_filter: Vec where has_range_filters(version), @@ -493,9 +519,6 @@ impl Message for SubscribeUpdate { 0x32 => _new_group_request: Option, ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id: None, @@ -503,6 +526,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: super::token::single(authorization_token)?, }) } } @@ -538,6 +563,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -562,6 +588,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -656,6 +683,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { @@ -687,6 +715,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -873,6 +902,7 @@ mod tests { properties_wanted: wanted, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, version); @@ -907,8 +937,10 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -916,8 +948,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -927,8 +959,10 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 1, object: 2 }, end_group: 100, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -938,8 +972,8 @@ mod tests { assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); assert_eq!(decoded.start_location, Location { group: 1, object: 2 }); assert_eq!(decoded.end_group, 100); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -997,6 +1031,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1031,8 +1066,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1040,8 +1077,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -1057,6 +1094,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1233,8 +1271,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1242,8 +1282,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } /// Cross-check: draft-17 emits an extra 0-byte (required_request_id_delta) that @@ -1256,8 +1296,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; @@ -1278,28 +1320,41 @@ mod tests { } /// Every parameter draft-20 lets a SUBSCRIBE carry that we don't act on still decodes, - /// with what we have to refuse recorded rather than failing the session. + /// with what we have to refuse recorded rather than failing the session. A second distinct + /// token, which the draft allows, refuses only the request. #[test] fn subscribe_decodes_every_draft20_parameter() { + let second: &[u8] = &[0x00, 0x03, 0x03, 0x00, 0xBB]; #[rustfmt::skip] - let body = subscribe_body(&[ - 0x06, // Number of Parameters - 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN: USE_VALUE, type 0, one byte - 0x00, 0x03, 0x03, 0x00, 0xBB, // a second one, which the draft allows - 0x0D, 0x00, // FORWARD (0x10) = 0, a uint8 - 0x15, 0x03, 0x00, 0x00, 0x01, // SUBGROUP_FILTER (0x25): SetID 0, range 0-1 - 0x0D, 0x07, // NEW_GROUP_REQUEST (0x32), a varint - 0x03, 0x00, // INCLUDE_PROPERTIES (0x35) = 0, a uint8 - ]); + let body = |second: &[u8]| subscribe_body(&<[&[u8]]>::concat(&[ + &[0x05 + u8::from(!second.is_empty())], // Number of Parameters + &[0x03, 0x03, 0x03, 0x00, 0xAA], // AUTHORIZATION TOKEN: USE_VALUE, type 0, one byte + second, // a second, distinct one, which the draft allows + &[0x0D, 0x00], // FORWARD (0x10) = 0, a uint8 + &[0x15, 0x03, 0x00, 0x00, 0x01], // SUBGROUP_FILTER (0x25): SetID 0, range 0-1 + &[0x0D, 0x07], // NEW_GROUP_REQUEST (0x32), a varint + &[0x03, 0x00], // INCLUDE_PROPERTIES (0x35) = 0, a uint8 + ])); for version in [Version::Draft20, Version::Draft21, Version::Draft22] { - let wire = body.clone(); + let wire = body(&[]); let mut buf = Decoder::new(&wire, version.into()); let msg = Subscribe::decode_msg(&mut buf, version).unwrap_or_else(|e| panic!("{version}: {e}")); assert!(buf.is_empty(), "{version}: trailing bytes"); + assert!(msg.authorization_token.is_some(), "{version}"); assert!(!msg.forward, "{version}"); assert!(msg.range_filters, "{version}"); assert!(!msg.properties_wanted, "{version}"); + + let wire = body(second); + let mut buf = Decoder::new(&wire, version.into()); + assert!( + matches!( + Subscribe::decode_msg(&mut buf, version), + Err(DecodeError::MultipleAuthTokens) + ), + "{version}" + ); } } @@ -1318,6 +1373,7 @@ mod tests { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }; #[rustfmt::skip] @@ -1379,6 +1435,7 @@ mod tests { properties_wanted: true, forward: false, range_filters: false, + authorization_token: None, }; let decoded: Subscribe = decode_message(&encode_message(&msg, version), version).unwrap(); assert!(!decoded.forward, "{version}"); diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index b77c53269f..bc03dd8b5b 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -79,12 +79,14 @@ impl Message for SubscribeNamespace<'_> { 0x03 => authorization_token: Vec, HIDDEN_PARAM => hidden: Option, ); - super::token::unique(&authorization_token)?; + let hidden = hidden_from_param(hidden)?; + // Last, so a malformed field closes the session before a token refusal. + super::token::single(authorization_token)?; Ok(Self { request_id, namespace, - hidden: hidden_from_param(hidden)?, + hidden, }) } } @@ -181,13 +183,15 @@ impl Message for SubscribeNamespaceLegacy<'_> { 0x10 => _forward: Option where matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17), HIDDEN_PARAM => hidden: Option, ); - super::token::unique(&authorization_token)?; + let hidden = hidden_from_param(hidden)?; + // Last, so a malformed field closes the session before a token refusal. + super::token::single(authorization_token)?; Ok(Self { request_id, namespace, subscribe_options, - hidden: hidden_from_param(hidden)?, + hidden, }) } } @@ -465,6 +469,7 @@ mod tests { hops: hop_path(&[7]), cost: 0, }), + authorization_token: None, }; let mut buf = Vec::new(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4af60b0fe6..915229459e 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -13,7 +13,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox, TaskSet, Tasks}, }; -use super::{Message, Version, cluster, error::request, group::ObjectExtensionsLength, peer}; +use super::{Message, Version, cluster, error::request, group::ObjectExtensionsLength, peer, request_update}; use crate::tail::{Reading, Settle, Tail}; use kio::Lock; @@ -557,11 +557,37 @@ struct BroadcastState { // active number of PUBLISH_NAMESPACE messages. count: usize, + // How many of them their own request token authorized. While any is live the + // namespace is exempt from the session's limit. + tokens: usize, + // Closes as the namespace is retracted, which closes every source minted under it. live: kio::Producer<()>, // Counts this namespace against the session's announce cap until it is retracted. - _slot: crate::session::Slot, + _slot: crate::auth::Hold, +} + +/// What authorized one advertisement. +enum Admit { + /// The session, so the limit holds it back while it does not cover the namespace. + Session, + /// Its own request token, which stands in for the limit. Carries the announce slot + /// taken before verification, when the namespace was not attached yet. + Token(Option), +} + +/// How a live advertisement ends, written once its route is released. +#[derive(Debug)] +enum Withdraw { + /// Nothing more to write: the peer withdrew it, or the stream ended. + Done, + /// Close our side and wait for the peer to read everything. + Close, + /// Refuse the request with this ID, then close. + Reject(RequestId, crate::auth::Refusal), + /// Refuse the update with this ID, then cancel the advertisement. + Cancel(RequestId, crate::auth::Refusal), } /// What one advertisement said, once its parameters are resolved against the session. @@ -734,22 +760,10 @@ where let mut state = self.state.lock(); let mut attached = Vec::new(); for (path, entry) in state.broadcasts.iter_mut() { - let allowed = permit.within_limit(path.as_str()); - match &entry.dynamic { - None if allowed => { - let mut route = entry.route.clone(); - if self.going_away.is_set() { - route.cost = crate::origin::Cost::DRAIN; - } - let Ok(dynamic) = self.origin.dynamic(path, route) else { - continue; - }; - tracing::info!(route = %self.origin.absolute(path), "namespace authorized again"); - entry.dynamic = Some(dynamic); - entry.generation += 1; - attached.push((path.clone(), entry.generation)); - } - _ => {} + if permit.within_limit(path.as_str()) + && let Ok(Some(generation)) = self.reattach(path, entry) + { + attached.push((path.clone(), generation)); } } drop(state); @@ -1040,7 +1054,7 @@ where // guards drop. let res = self.run_namespace_entries(&mut stream, &prefix, &peer, &mut live).await; for path in live { - let _ = self.stop_announce(path); + let _ = self.stop_announce(path, false); } res } @@ -1084,7 +1098,7 @@ where // leave subscriptions on a path the peer no longer offers. tracing::debug!(%path, "dropping reflected namespace"); if live.remove(&path) { - let _ = self.stop_announce(path); + let _ = self.stop_announce(path, false); } continue; }; @@ -1095,7 +1109,7 @@ where // down merely because an update arrived. self.update_announce(path, advert)?; } else { - match self.start_announce(path.clone(), advert) { + match self.start_announce(path.clone(), advert, Admit::Session) { Ok(()) => { live.insert(path); } @@ -1114,7 +1128,7 @@ where let path = prefix.join(&msg.suffix); tracing::debug!(%path, "namespace_done"); if live.remove(&path) { - let _ = self.stop_announce(path); + let _ = self.stop_announce(path, false); } } _ => { @@ -1146,8 +1160,12 @@ where let task = match id { ietf::Publish::ID => { let msg = match ietf::Publish::decode_msg(&mut data, this.version) { - Err(DecodeError::MalformedAuthToken) => { - return Ok(this.reject_token(stream, request::Kind::Publish, body.clone())); + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } + return Ok(this.reject_token(stream, request::Kind::Publish, body.clone(), err)); } msg => msg?, }; @@ -1166,8 +1184,12 @@ where // A negotiated session that omits HOP_PATH fails the decode here, which // the dispatcher turns into the protocol violation the draft requires. let msg = match ietf::PublishNamespace::decode_body(&mut data, this.version, peer.negotiated()) { - Err(DecodeError::MalformedAuthToken) => { - return Ok(this.reject_token(stream, request::Kind::PublishNamespace, body.clone())); + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); + } + return Ok(this.reject_token(stream, request::Kind::PublishNamespace, body.clone(), err)); } msg => msg?, }; @@ -1207,10 +1229,11 @@ where stream: Stream, kind: request::Kind, body: ietf::Body, + err: DecodeError, ) -> MaybeSendBox<'static, ()> { let version = self.version; async move { - if let Err(err) = super::token::reject(stream, kind, body, version).await { + if let Err(err) = super::token::reject(stream, kind, body, err, version).await { tracing::debug!(%err, "token refusal failed"); } } @@ -1279,38 +1302,130 @@ where return Ok(()); }; - match self.start_announce(path.clone(), advert) { - Ok(_) => { - if let Err(err) = self.write_ok(&mut stream, request_id).await { - // Local rollback, not a peer unannounce: don't count announce bytes. - let _ = self.stop_announce(path); - return Err(err); + // Our authorization of the peer (the limit) admits an announce without a token, and + // holds back one it does not cover until it does. An AUTHORIZATION TOKEN on the + // announce goes to the application to verify for this announce alone (MoQ + // request-token), even where the limit covers it, and a verified one is exempt from the + // limit while it lasts. + let token = match &msg.authorization_token { + Some(token) if self.auth.token_decides(crate::auth::Direction::Subscribe) => Some(token.0.clone()), + _ => None, + }; + let (admit, token_grant) = match token { + None => (Admit::Session, None), + Some(token) => { + // Counted against the cap while the application verifies, unless the + // namespace already holds a slot. + let slot: Option = match self.state.lock().broadcasts.contains_key(&path) { + true => None, + false => Some(std::sync::Arc::new(self.acquire_announce()?)), + }; + let verdict = self.auth.verify_request(token, slot.clone()); + // The session closing or the peer withdrawing the announce withdraws the token. + let mut session = self.session.clone(); + let version = self.version; + let verified = kio::wait(|waiter| { + let mut cx = waiter.context(); + if session.poll_closed(&mut cx).is_ready() + || super::request_stream::poll_withdrawn(&mut stream, version, &mut cx).is_ready() + { + return Poll::Ready(None); + } + verdict.poll_grant(waiter).map(Some) + }) + .await; + let Some(verified) = verified else { + return Ok(()); + }; + let admitted = crate::auth::RequestGrant::new( + &self.runtime, + verdict, + verified, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + slot.clone(), + ); + match admitted { + Ok(grant) => (Admit::Token(slot), Some(grant)), + Err(refusal) => { + self.write_error(&mut stream, request_id, &refusal.error, &refusal.reason) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } } } - Err(err) => { - self.write_error(&mut stream, request_id, &err, &err.to_string()) + }; + let token = token_grant.is_some(); + + // Everything the advertisement does once admitted runs under its grant: one that ends + // at any await, the first answer included, drops it right there. `attached` says the + // route is up, and `acked` that the OK went out, which decides how the peer is told. + let mut attached = false; + let mut acked = false; + let lived = crate::auth::RequestGrant::guard(token_grant.as_ref(), async { + if let Err(err) = self.start_announce(path.clone(), advert, admit) { + return Ok(Withdraw::Reject(request_id, err.into())); + } + attached = true; + // Buffered whole before its first await, so however this is dropped from here, the + // advertisement can still end on the wire. + acked = true; + self.write_ok(&mut stream, request_id).await?; + + // An endpoint updates an advertisement with REQUEST_UPDATE on the stream that + // already carries it, so keep reading until the stream ends: a close on + // draft-17+, or v14-16's PublishNamespaceDone (see `terminal_publish_namespace`). + self.run_publish_namespace_updates( + &mut stream, + &path, + msg.cluster, + peer, + &mut attached, + token_grant.clone(), + ) + .await + }) + .await; + + // The route goes before anything waits on the peer, so a peer that stops reading + // cannot keep it routed. A stream that detached mid-flight (a reflected update) has + // nothing left to release. + self.release_announce(&path, &mut attached, token)?; + + let withdraw = match lived { + Ok(withdraw) => withdraw?, + Err(refusal) => { + tracing::info!(%path, err = %refusal.error, reason = %refusal.reason, "request token grant ended"); + if !acked { + self.write_error(&mut stream, request_id, &refusal.error, &refusal.reason) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + self.cancel_announce(&mut stream, request_id, &path, &refusal.error, &refusal.reason) .await?; + return Err(refusal.error); + } + }; + match withdraw { + Withdraw::Done => {} + Withdraw::Close => { let _ = stream.writer.close().await; - return Ok(()); + } + Withdraw::Reject(id, refusal) => { + self.write_error(&mut stream, id, &refusal.error, &refusal.reason) + .await?; + let _ = stream.writer.close().await; + } + Withdraw::Cancel(id, refusal) => { + self.write_error(&mut stream, id, &refusal.error, &refusal.reason) + .await?; + self.cancel_announce(&mut stream, request_id, &path, &refusal.error, &refusal.reason) + .await?; } } - - // An endpoint updates an advertisement with REQUEST_UPDATE on the stream that - // already carries it, so keep reading until the stream ends: a close on - // draft-17+, or v14-16's PublishNamespaceDone (see `terminal_publish_namespace`). - // - // `attached` survives the call so a stream that detached mid-flight (a reflected - // update) is not released twice here. - let mut attached = true; - let res = self - .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached) - .await; - - if attached { - self.stop_announce(path)?; - } - - res + Ok(()) } /// Whether `type_id` retracts a PUBLISH_NAMESPACE rather than updating it. @@ -1330,7 +1445,8 @@ where } } - /// Read advertisement updates off a live PUBLISH_NAMESPACE stream until it closes. + /// Read advertisement updates off a live PUBLISH_NAMESPACE stream until it closes, and + /// return how it ends, which the caller writes once the route is released. /// /// `held` is what the peer advertised, kept current because a REQUEST_UPDATE carries /// only what changed. Each one is answered with REQUEST_OK, or REQUEST_ERROR and a @@ -1343,35 +1459,150 @@ where mut held: Option, peer: cluster::Peer, attached: &mut bool, - ) -> Result<(), Error> { + token_grant: Option, + ) -> Result { + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant (MoQ + // request-token); its verify is raced against the stream in the loop's single wait + // (never a bare await), so a peer ending the announce ends it whatever the acceptor + // does, and the caller's guard ends it when the old grant does. Messages behind a + // pending verdict wait in `renewal`, handled in order once it resolves. + let mut renewal = request_update::Renewal::<(u64, ietf::Body)>::default(); + // Draft-19+ only: the peer finished its send direction, which does not withdraw the + // advertisement there, so stop reading and keep watching the writer. + let mut finished = false; + + // What one turn of the loop resolved to. + enum Turn { + // A whole message off the stream, or one drained from the queue. + Message(u64, ietf::Body), + // The peer finished its send direction. + Fin, + // Draft-17+: our send direction closed (the peer stopped reading or reset it). + WriterClosed(Result<(), Error>), + // The read failed. + Failed(Error), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(request_update::Verdict), + } + + // Request IDs of updates queued behind a pending verdict, already spent. + let mut spent = std::collections::HashSet::new(); loop { - let next = kio::wait(|waiter| { - let mut cx = waiter.context(); - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) - { - stream.reader.abort(&Error::Cancel); - return Poll::Ready(result.map(|()| None)); + // Handle one buffered update before reading the next, so answers stay in order. + let turn = if let Some((id, body)) = renewal.next() { + Turn::Message(id, body) + } else { + let version = self.version; + kio::wait(|waiter| { + let mut cx = waiter.context(); + if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + && let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) + { + return Poll::Ready(Turn::WriterClosed(result)); + } + if let Poll::Ready(renewed) = renewal.poll(waiter) { + return Poll::Ready(Turn::Renewal(renewed)); + } + // Keep reading while a verdict is pending, so a withdrawal still ends the + // announce: a buffered update must not mask a later terminal. + if !finished { + return match stream + .reader + .poll_decode_maybe::(&mut cx) + { + Poll::Ready(Ok(Some(super::request_stream::Framed(id, body)))) => { + Poll::Ready(Turn::Message(id, body)) + } + Poll::Ready(Ok(None)) => Poll::Ready(Turn::Fin), + Poll::Ready(Err(err)) => Poll::Ready(Turn::Failed(err)), + Poll::Pending => Poll::Pending, + }; + } + Poll::Pending + }) + .await + }; + + let (type_id, body) = match turn { + // A non-terminal message read while a verdict is pending waits for it. + Turn::Message(id, body) if renewal.pending() && !self.terminal_publish_namespace(id) => { + if id == ietf::PublishNamespaceUpdate::ID { + // Its request ID is spent on arrival, so one dropped with the queue stays spent. + if self.version != Version::Draft16 { + let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; + self.spend(update_id)?; + spent.insert(update_id); + } + // Decoded on arrival too, so a session-fatal update still closes the session + // when a withdrawal drops the queue. A token refusal only refuses this + // announce, which waits for the update's turn. + let mut data = body.decoder(self.version); + match ietf::PublishNamespaceUpdate::decode_msg(&mut data, self.version) { + Err(err) if super::token::refuses_request(&err) => {} + msg => { + msg?; + } + } + if !data.is_empty() { + return Err(Error::WrongSize); + } + } + match renewal.queue((id, body), self.version) { + Ok(()) => continue, + // The dispatcher closes only on is_protocol_violation, which excludes + // Error::Session, so the session is closed here. + Err(request_update::Overflow::Session) => { + return Err(request_update::too_many(&mut self.session)); + } + // Drafts below 19 negotiate no limit, so this is a local memory guard, not a + // protocol fault: end this announce, never the session. + Err(request_update::Overflow::Request) => return Ok(Withdraw::Close), + } } - stream.reader.poll_varint_maybe(&mut cx) - }) - .await?; - let type_id: u64 = match next { - Some(id) => id, - None => { + // A terminal read while a verdict is pending is handled now, ahead of any queued + // update: nothing more is owed once the peer retracts. + Turn::Message(id, body) => (id, body), + Turn::Fin => { if super::request_stream::fin_cancels(self.version) { - return Ok(()); + return Ok(Withdraw::Done); } // The advertisement outlives the peer's send direction. - return stream.writer.closed().await; + finished = true; + continue; + } + // Already finished: the advertisement outlived the peer's send direction until now. + Turn::WriterClosed(result) if finished => return result.map(|()| Withdraw::Done), + Turn::WriterClosed(result) => { + stream.reader.abort(&Error::Cancel); + result?; + if !super::request_stream::fin_cancels(self.version) { + stream.writer.closed().await?; + } + return Ok(Withdraw::Done); + } + Turn::Failed(err) => return Err(err), + Turn::Renewal(request_update::Verdict { + verdict, + grant: res, + request_id: rid, + }) => { + // Only a token-authorized announce starts a renewal. + let rg = token_grant.as_ref().expect("a renewal without a request grant"); + // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). + let Err(refusal) = rg.renew(verdict, res) else { + self.write_ok(stream, rid).await?; + continue; + }; + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // renewal ends only this announce. Answer REQUEST_ERROR, then close the stream; + // the session stays up and the old grant does not survive. + return Ok(Withdraw::Cancel(rid, refusal)); } }; - // Drafts 14-16 carry no advertisement parameters to update, so an update the - // adapter routed here changes nothing. - if type_id == ietf::SubscribeUpdate::ID - && matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - { - stream.reader.decode::().await?; + + // Drafts 14-15 have no update for an advertisement, so a SUBSCRIBE_UPDATE the adapter + // routed here changes nothing. Draft-16's REQUEST_UPDATE renews its token. + if type_id == ietf::SubscribeUpdate::ID && matches!(self.version, Version::Draft14 | Version::Draft15) { continue; } let terminal = self.terminal_publish_namespace(type_id); @@ -1382,7 +1613,6 @@ where return Err(Error::UnexpectedMessage); } - let body: ietf::Body = stream.reader.decode().await?; let mut data = body.decoder(self.version); if terminal { @@ -1391,29 +1621,29 @@ where return Err(Error::WrongSize); } tracing::debug!(%path, "publish_namespace_done"); - return Ok(()); + return Ok(Withdraw::Done); } - // An update spends its own request ID, even one refused below. - let request_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; - if let Err(err) = self.control.accept(request_id) { - self.session.clone().close( - super::error::to_session_code(&crate::SessionError::from(&err)), - "invalid request ID", - ); - return Err(err); + // An update spends its own request ID, even one refused below. On draft-16 the + // adapter already spent it when it routed the update here, and a queued one was + // spent when it arrived. + let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; + if self.version != Version::Draft16 && !spent.remove(&update_id) { + self.spend(update_id)?; } let msg = match ietf::PublishNamespaceUpdate::decode_msg(&mut data, self.version) { - // Section 8.9 rejects the update, which withdraws the advertisement as any - // refused update does. - Err(DecodeError::MalformedAuthToken) => { - let err = Error::Decode(DecodeError::MalformedAuthToken); - self.write_error(stream, request_id, &err, "repeated AUTHORIZATION TOKEN") - .await?; - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; + // A token refusal rejects the update, which withdraws the advertisement as + // any refused update does. + Err(err) if super::token::refuses_request(&err) => { + // Junk after the fields is fatal, which a token refusal must not hide. + if !data.is_empty() { + return Err(Error::WrongSize); } - return Ok(()); + let refusal = crate::auth::Refusal { + reason: super::token::reason(&err).to_string(), + error: Error::Decode(err), + }; + return Ok(Withdraw::Cancel(update_id, refusal)); } msg => msg?, }; @@ -1423,55 +1653,128 @@ where return Err(Error::WrongSize); } - // An omitted parameter keeps its value, so the update lands on what the peer - // already advertised. The parameters exist only on a session that negotiated - // the extension; anywhere else they are the peer's violation. - // A different original publisher applies in place too: the origin drains what - // the old one already serves and never splices the two. + // Cluster parameters and a token renewal can ride the same update, so apply the + // routing first, for every update that carries it, before dealing with the token. + // A different original publisher applies in place too: the origin drains what the + // old one already serves and never splices the two. The parameters exist only on a + // session that negotiated the extension; anywhere else they are the peer's violation. + let carries_cluster = msg.hops.is_some() || msg.cost.is_some(); held = match &held { Some(current) => Some(msg.apply(current)), - None if msg.hops.is_some() || msg.cost.is_some() => { + None if carries_cluster => { tracing::warn!(%path, "cluster parameters on a session that negotiated none"); return Err(Error::ProtocolViolation); } None => None, }; - // A path that now runs through us is unusable, so detach rather than keep - // serving it. The update itself is accepted, and reading continues: this - // stream is the only channel the advertisement has, so a later clean path - // arrives here or nowhere. Ending the stream is also not ours to do, since a - // peer MAY legitimately send a path carrying our Hop ID when a redundant - // sibling shares it. - let Some(advert) = self.route(held.as_ref(), &peer) else { - if std::mem::take(attached) { - tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone()); - } - self.write_ok(stream, msg.request_id).await?; - continue; - }; - - tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); - let applied = match *attached { - true => self.update_announce(path.clone(), advert), - // Re-attach: a clean path replaced the reflected one we detached from. - false => self.start_announce(path.clone(), advert).map(|()| *attached = true), + // Re-route only when the update actually changes the route (an omitted parameter + // keeps its value, so a token-only update leaves it untouched). A path that now runs + // through us is unusable, so detach rather than keep serving it; reading continues, + // since this stream is the advertisement's only channel and a later clean path + // arrives here or nowhere. Ending the stream is not ours to do: a peer MAY + // legitimately send a path carrying our Hop ID when a redundant sibling shares it. + let applied: Result<(), Error> = match carries_cluster { + true => match self.route(held.as_ref(), &peer) { + None => { + if std::mem::take(attached) { + tracing::debug!(%path, "publish_namespace now loops back; detaching"); + let _ = self.stop_announce(path.clone(), token_grant.is_some()); + } + Ok(()) + } + Some(advert) => { + tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); + match *attached { + true => self.update_announce(path.clone(), advert), + // Re-attach: a clean path replaced the reflected one we detached from. + false => { + // The grant still holds the slot it was admitted with, so reuse it. + let admit = match &token_grant { + Some(grant) => Admit::Token(grant.hold()), + None => Admit::Session, + }; + self.start_announce(path.clone(), advert, admit) + .map(|()| *attached = true) + } + } + } + }, + false => Ok(()), }; - match applied { - Ok(()) => self.write_ok(stream, msg.request_id).await?, - Err(err) => { - tracing::warn!(%path, %err, "publish_namespace update refused"); - self.write_error(stream, msg.request_id, &err, &err.to_string()).await?; - // The close is the withdrawal; the caller releases what was attached. - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; + // An unroutable apply withdraws the announce whether or not a token also rides it. + // The close is the withdrawal. + if let Err(err) = applied { + tracing::warn!(%path, %err, "publish_namespace update refused"); + return Ok(Withdraw::Reject(msg.request_id, err.into())); + } + + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant + // (MoQ request-token), when the announce is token-authorized. The verify is not + // awaited here: it becomes the pending renewal raced against the deadline above. + // The routing above is already applied, so the renewal's answer (written when its + // verdict resolves) is this update's single response, cluster parameters included. + if let Some(token) = msg.authorization_token { + match &token_grant { + Some(grant) => { + renewal.verify(grant.verify_renewal(&self.auth, token.0), msg.request_id); + continue; } - return Ok(()); + // Where tokens decide, a token on an announce admitted without one is refused + // rather than taken unread, so every token this side admits is verified. + None if self.auth.token_decides(crate::auth::Direction::Subscribe) => { + let refusal = crate::auth::Refusal { + error: Error::Unsupported, + reason: "AUTHORIZATION TOKEN on an announce admitted without one".to_string(), + }; + return Ok(Withdraw::Cancel(msg.request_id, refusal)); + } + None => {} } } + + // No token rides this update: acknowledge it now. + self.write_ok(stream, msg.request_id).await?; + } + } + + /// Retract an advertisement's route ahead of a withdrawal that waits on the peer, so a peer + /// that stops reading cannot keep it routed. `attached` makes it once only. + fn release_announce(&mut self, path: &PathOwned, attached: &mut bool, token: bool) -> Result<(), Error> { + if std::mem::take(attached) { + self.stop_announce(path.clone(), token)?; + } + Ok(()) + } + + /// End an advertisement we accepted, leaving the session up: a closed stream from draft-17, + /// and PUBLISH_NAMESPACE_CANCEL before it, where the request outlives its stream. + async fn cancel_announce( + &self, + stream: &mut Stream, + request_id: RequestId, + path: &PathOwned, + err: &Error, + reason: &str, + ) -> Result<(), Error> { + if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + stream + .writer + .encode_message(&ietf::PublishNamespaceCancel { + track_namespace: path.clone(), + request_id, + error_code: request::to_code(err, request::Kind::PublishNamespace, self.version), + reason_phrase: reason.into(), + }) + .await?; } + // An OK cut short by the grant ending is still buffered, and goes out ahead of the FIN. + std::future::poll_fn(|cx| stream.writer.poll_flush(cx)).await?; + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + Ok(()) } /// Reject an incoming PUBLISH. @@ -1514,14 +1817,26 @@ where } /// Send OK on the bidi stream. + /// Spend a peer's request ID, closing the session on one it already used. + fn spend(&self, id: RequestId) -> Result<(), Error> { + if let Err(err) = self.control.accept(id) { + self.session.clone().close( + super::error::to_session_code(&crate::SessionError::from(&err)), + "invalid request ID", + ); + return Err(err); + } + Ok(()) + } + async fn write_ok(&self, stream: &mut Stream, request_id: RequestId) -> Result<(), Error> { match self.version { Version::Draft14 => { - stream.writer.varint(ietf::PublishNamespaceOk::ID).await?; + stream.writer.buffer_varint(ietf::PublishNamespaceOk::ID)?; stream.writer.encode(&ietf::PublishNamespaceOk { request_id }).await?; } Version::Draft15 | Version::Draft16 => { - stream.writer.varint(ietf::RequestOk::ID).await?; + stream.writer.buffer_varint(ietf::RequestOk::ID)?; stream .writer .encode(&ietf::RequestOk { @@ -1531,7 +1846,7 @@ where .await?; } _ => { - stream.writer.varint(ietf::RequestOk::ID).await?; + stream.writer.buffer_varint(ietf::RequestOk::ID)?; stream .writer .encode(&ietf::RequestOk { @@ -1556,7 +1871,7 @@ where match self.version { Version::Draft14 => { - stream.writer.varint(ietf::PublishNamespaceError::ID).await?; + stream.writer.buffer_varint(ietf::PublishNamespaceError::ID)?; stream .writer .encode(&ietf::PublishNamespaceError { @@ -1567,7 +1882,7 @@ where .await?; } Version::Draft15 | Version::Draft16 => { - stream.writer.varint(ietf::RequestError::ID).await?; + stream.writer.buffer_varint(ietf::RequestError::ID)?; stream .writer .encode(&ietf::RequestError { @@ -1579,7 +1894,7 @@ where .await?; } _ => { - stream.writer.varint(ietf::RequestError::ID).await?; + stream.writer.buffer_varint(ietf::RequestError::ID)?; stream .writer .encode(&ietf::RequestError { @@ -1647,18 +1962,52 @@ where /// Attach the route for one newly advertised namespace, bumping its refcount. /// /// Pair with [`Self::stop_announce`]. - fn start_announce(&mut self, path: PathOwned, advert: Advertised) -> Result<(), Error> { + fn start_announce(&mut self, path: PathOwned, advert: Advertised, admit: Admit) -> Result<(), Error> { let mut state = self.state.lock(); + let (token, slot) = match admit { + Admit::Session => (false, None), + Admit::Token(slot) => (true, slot), + }; let existing = state.broadcasts.contains_key(&path); - self.attach(&mut state, path.clone(), advert)?; - if existing && let Some(entry) = state.broadcasts.get_mut(&path) { - // The path was already attached, so this is one more advertisement for - // it; only a freshly created entry starts at one and skips this. - entry.count += 1; + self.attach(&mut state, path.clone(), advert, slot, token)?; + if !existing { + return Ok(()); + } + let Some(entry) = state.broadcasts.get_mut(&path) else { + return Ok(()); + }; + // A token stands in for the limit, so a namespace the limit withheld attaches now. + let attached = match token { + true => self.reattach(&path, entry)?, + false => None, + }; + // The path was already attached, so this is one more advertisement for it; only a + // freshly created entry starts at one and skips this. + entry.count += 1; + entry.tokens += usize::from(token); + drop(state); + if let Some(generation) = attached { + self.serve_route(path, generation); } Ok(()) } + /// Attach the route of a namespace the limit withheld, returning the generation + /// to serve. `None` when it is attached already. + fn reattach(&self, path: &PathOwned, entry: &mut BroadcastState) -> Result, Error> { + if entry.dynamic.is_some() { + return Ok(None); + } + let mut route = entry.route.clone(); + if self.going_away.is_set() { + route.cost = crate::origin::Cost::DRAIN; + } + entry.dynamic = Some(self.origin.dynamic(path, route)?); + entry.generation += 1; + tracing::info!(route = %self.origin.absolute(path), "namespace authorized again"); + Ok(Some(entry.generation)) + } + /// Apply a changed advertisement to a namespace that is already attached. /// /// An update replaces the advertisement atomically: the refcount does not move, @@ -1668,7 +2017,7 @@ where if !state.broadcasts.contains_key(&path) { return Err(Error::NotFound); } - self.attach(&mut state, path, advert)?; + self.attach(&mut state, path, advert, None, false)?; Ok(()) } @@ -1678,7 +2027,17 @@ where /// This is the semantic heart of the mapping: a moq-transport namespace IS a /// prefix route, so a PUBLISH_NAMESPACE advertises the whole prefix and paths /// beneath it materialize on demand. - fn attach(&self, state: &mut State, path: PathOwned, advert: Advertised) -> Result<(), Error> { + /// + /// `slot` is one taken ahead for a new entry, and `token` exempts a new entry from + /// the limit. + fn attach( + &self, + state: &mut State, + path: PathOwned, + advert: Advertised, + slot: Option, + token: bool, + ) -> Result<(), Error> { let Advertised { mut route } = advert; // A namespace published after the peer's GOAWAY starts out draining, so @@ -1700,21 +2059,20 @@ where } Entry::Vacant(entry) => { // A peer past its limits loses the session, not just this namespace. - let slot = self.announces.acquire().inspect_err(|err| { - self.session.clone().close( - super::error::to_session_code(&crate::SessionError::from(err)), - "too many announcements", - ); - })?; + let slot = match slot { + Some(slot) => slot, + None => std::sync::Arc::new(self.acquire_announce()?), + }; // Outside the session's limit: held, not refused, so a wider limit can // attach it while the peer still advertises it. - if !self.auth.within_limit(crate::auth::Direction::Subscribe, path.as_str()) { + if !token && !self.auth.within_limit(crate::auth::Direction::Subscribe, path.as_str()) { tracing::debug!(route = %self.origin.absolute(&path), "withholding announce outside the limit"); entry.insert(BroadcastState { route, dynamic: None, generation: 0, count: 1, + tokens: usize::from(token), live: Default::default(), _slot: slot, }); @@ -1728,6 +2086,7 @@ where dynamic: Some(dynamic), generation: 0, count: 1, + tokens: usize::from(token), live: Default::default(), _slot: slot, }); @@ -1740,18 +2099,39 @@ where } } + /// Take one slot of the session's announce cap. A peer past it loses the session. + fn acquire_announce(&self) -> Result { + self.announces.acquire().inspect_err(|err| { + self.session.clone().close( + super::error::to_session_code(&crate::SessionError::from(err)), + "too many announcements", + ); + }) + } + /// Release one advertisement of `path`, closing its sources when it was the last. - fn stop_announce(&mut self, path: PathOwned) -> Result<(), Error> { + /// `token` says its own request token authorized it. + fn stop_announce(&mut self, path: PathOwned, token: bool) -> Result<(), Error> { let mut state = self.state.lock(); match state.broadcasts.entry(path.clone()) { Entry::Occupied(mut entry) => { - entry.get_mut().count -= 1; - if entry.get().count == 0 { + let state = entry.get_mut(); + state.count -= 1; + state.tokens -= usize::from(token); + if state.count == 0 { tracing::debug!(route = %self.origin.absolute(&path), "unannounced"); // Dropping the entry retracts the route (its announcement drops) and // closes its sources (its token closes). entry.remove(); + } else if state.tokens == 0 + && state.dynamic.is_some() + && !self.auth.within_limit(crate::auth::Direction::Subscribe, path.as_str()) + { + // The last token that stood in for the limit is gone: hold the route back. + tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); + state.dynamic = None; + state.live = Default::default(); } } Entry::Vacant(_) => return Err(Error::NotFound), @@ -1779,7 +2159,7 @@ where /// Serve materialization requests for one announced namespace: mint a source /// per requested path and serve its track requests until the route is /// retracted or the session dies. Tracks in flight at a retraction run to - /// their own end. + /// their own end, unless the announce was what authorized them. async fn run_route(&self, path: PathOwned, generation: u64) { let mut broadcasts = TaskSet::owned(); let mut closed_session = self.session.clone(); @@ -1817,7 +2197,8 @@ where if entry.generation != generation { return Poll::Ready(None); } - if excluded && entry.dynamic.is_some() { + // A namespace its own request token authorized is exempt from the limit. + if excluded && entry.tokens == 0 && entry.dynamic.is_some() { tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); entry.dynamic = None; // Dropping the namespace's token closes every source minted under it. @@ -1958,8 +2339,9 @@ where let path = path.to_owned(); let broadcast = broadcast.clone(); + let route = route.clone(); subscribes.push(async move { - this.run_subscribe(path, broadcast, request).await; + this.run_subscribe(path, broadcast, route, request).await; }); } @@ -1970,26 +2352,30 @@ where &mut self, broadcast_path: Path<'_>, // Held for the subscription's lifetime but never watched: the broadcast ending - // is a retraction, which does not disturb a subscription already in flight. + // is a retraction, which does not disturb a subscription the scope allows. _broadcast: broadcast::Dynamic, + // Open while the namespace stays announced and authorized, which its own token may + // have done in place of the scope. `None` once retracted. + route: Option>, request: track::Request, ) { // Data streams wait on the alias bound by SUBSCRIBE_OK, so leave the model request // pending until its immutable track metadata is known. // Subscribe only to what our grant covers (MoQ Auth), and cancel once it no longer // does, leaving the rest of the session alone. - if !self - .auth - .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) - { - request.reject(Error::Unauthorized); - return; - } - let mut gate = crate::auth::Gate::new( + let (auth, path, direction) = ( self.auth.clone(), broadcast_path.to_owned(), crate::auth::Direction::Subscribe, ); + let mut gate = match route { + Some(live) => crate::auth::Gate::announced(auth, path, direction, live), + None => crate::auth::Gate::new(auth, path, direction), + }; + if !gate.allows() { + request.reject(Error::Unauthorized); + return; + } let track_name = request.name().to_owned(); // Group FETCHes for cache misses: standalone, so they outlive each subscription. @@ -2127,6 +2513,7 @@ where properties_wanted: matches!(target, Target::Request(_)), forward: true, range_filters: false, + authorization_token: None, }) .await } @@ -5536,7 +5923,9 @@ mod tests { drop(consumer); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); moq_net_sim::timeout(std::time::Duration::from_secs(1), serving) @@ -5586,7 +5975,9 @@ mod tests { let request = dynamic.requested_track().await.expect("no track requested"); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); // Let the SUBSCRIBE go out. No SUBSCRIBE_OK is coming, so the subscription never @@ -5650,7 +6041,9 @@ mod tests { let request = dynamic.requested_track().await.expect("no track requested"); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); // Let the SUBSCRIBE go out, then retract the broadcast before any response. @@ -5708,7 +6101,9 @@ mod tests { let mut fetch = None; let serving = match kind { Allocated::Subscribe => moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }), Allocated::TrackStatus => moq_net_sim::spawn(async move { assert!( @@ -5834,7 +6229,9 @@ mod tests { let request = dynamic.requested_track().await.expect("no track requested"); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -5977,7 +6374,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); // Let the SUBSCRIBE go out and the SUBSCRIBE_OK come back, so the subscription is @@ -6056,7 +6455,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -6146,7 +6547,9 @@ mod tests { let request = dynamic.requested_track().await.expect("no track requested"); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -6224,7 +6627,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; let _subscription = track.subscribe(None); @@ -6339,10 +6744,10 @@ mod tests { advert(&subscriber), advert(&subscriber), ); - subscriber.start_announce(a.clone(), first).unwrap(); - subscriber.start_announce(a.clone(), repeat).unwrap(); + subscriber.start_announce(a.clone(), first, Admit::Session).unwrap(); + subscriber.start_announce(a.clone(), repeat, Admit::Session).unwrap(); assert!(matches!( - subscriber.start_announce(b.clone(), over), + subscriber.start_announce(b.clone(), over, Admit::Session), Err(Error::TooManyRequests) )); assert_eq!( @@ -6353,9 +6758,9 @@ mod tests { )] ); - subscriber.stop_announce(a.clone()).unwrap(); - subscriber.stop_announce(a).unwrap(); - subscriber.start_announce(b, last).unwrap(); + subscriber.stop_announce(a.clone(), false).unwrap(); + subscriber.stop_announce(a, false).unwrap(); + subscriber.start_announce(b, last, Admit::Session).unwrap(); } /// moq-transport carries no hop ids, so a peer's broadcasts are marked @@ -6385,7 +6790,7 @@ mod tests { let advert = subscriber.route(None, &cluster::Peer::default()).expect("route"); subscriber - .start_announce(crate::Path::new("room/host").to_owned(), advert) + .start_announce(crate::Path::new("room/host").to_owned(), advert, Admit::Session) .unwrap(); let mut announced = consumer.announced(); @@ -6445,7 +6850,7 @@ mod tests { ); let advert = subscriber.route(None, &cluster::Peer::default()).expect("route"); subscriber - .start_announce(crate::Path::new("room/host").to_owned(), advert) + .start_announce(crate::Path::new("room/host").to_owned(), advert, Admit::Session) .unwrap(); // No announce churn: the upstream route stays the best one, on both cursors. @@ -6485,7 +6890,7 @@ mod tests { ); let advert = subscriber.route(None, &cluster::Peer::default()).expect("route"); subscriber - .start_announce(crate::Path::new("room/host").to_owned(), advert) + .start_announce(crate::Path::new("room/host").to_owned(), advert, Admit::Session) .unwrap(); subscriber }; @@ -6575,7 +6980,7 @@ mod tests { let mut subscriber = subscriber; subscriber - .start_announce(crate::Path::new("room/host").to_owned(), advertised) + .start_announce(crate::Path::new("room/host").to_owned(), advertised, Admit::Session) .unwrap(); let route = routed_now(&consumer, "room/host").expect("routed"); @@ -6772,10 +7177,10 @@ mod tests { let path = crate::Path::new("room/host").to_owned(); let advert = subscriber.route(None, &cluster::Peer::default()).expect("route"); - subscriber.start_announce(path.clone(), advert).unwrap(); + subscriber.start_announce(path.clone(), advert, Admit::Session).unwrap(); settle().await; - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "an explicit NAMESPACE_DONE must retract the route", @@ -6812,6 +7217,7 @@ mod tests { request_id: RequestId(0), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }; let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( stream, @@ -6874,6 +7280,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -6930,6 +7337,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -6961,12 +7369,12 @@ mod tests { for _ in 0..2 { let advert = subscriber.route(None, &peer).expect("route"); - subscriber.start_announce(path.clone(), advert).unwrap(); + subscriber.start_announce(path.clone(), advert, Admit::Session).unwrap(); } settle().await; // One advertisement's stream dies: the other still holds the source. - subscriber.stop_announce(path.clone()).unwrap(); + subscriber.stop_announce(path.clone(), false).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_some(), @@ -6974,7 +7382,7 @@ mod tests { ); // The last owner retracts: the broadcast closes with it. - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_none(), @@ -7055,7 +7463,7 @@ mod tests { .with_hops(hop_path(&[7, 9]).hops().clone()) .with_cost(4), }; - subscriber.start_announce(path.clone(), first).unwrap(); + subscriber.start_announce(path.clone(), first, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // A new chain and cost: the route updates in place. @@ -7073,7 +7481,7 @@ mod tests { // One advertisement, so one unannounce detaches it. If the update had bumped the // refcount, this would leave the route stranded. - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -7106,7 +7514,9 @@ mod tests { &peer, ) .expect("route"); - subscriber.start_announce(path.clone(), advertised).unwrap(); + subscriber + .start_announce(path.clone(), advertised, Admit::Session) + .unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // The peer re-advertises the same path cheaper: it started carrying it. @@ -7123,7 +7533,7 @@ mod tests { ); // One advertisement, so one unannounce detaches it. - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -7144,17 +7554,17 @@ mod tests { let advert = cluster::Advert { hops, cost: 0 }; let first = subscriber.route(Some(&advert), &peer).expect("route"); - subscriber.start_announce(path.clone(), first).unwrap(); + subscriber.start_announce(path.clone(), first, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); let second = subscriber.route(Some(&advert), &peer).expect("route"); - subscriber.start_announce(path.clone(), second).unwrap(); + subscriber.start_announce(path.clone(), second, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone()).unwrap(); + subscriber.stop_announce(path.clone(), false).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -7172,18 +7582,18 @@ mod tests { // What a PUBLISH_NAMESPACE with no cluster parameters resolves to. let first = subscriber.route(None, &peer).expect("route"); - subscriber.start_announce(path.clone(), first).unwrap(); + subscriber.start_announce(path.clone(), first, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // The NAMESPACE for the same namespace arrives second. let second = subscriber.route(None, &peer).expect("route"); - subscriber.start_announce(path.clone(), second).unwrap(); + subscriber.start_announce(path.clone(), second, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone()).unwrap(); + subscriber.stop_announce(path.clone(), false).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -7206,7 +7616,7 @@ mod tests { cost: 0, }; let advert = subscriber.route(Some(&clean), &peer).expect("route"); - subscriber.start_announce(path.clone(), advert).unwrap(); + subscriber.start_announce(path.clone(), advert, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); // The peer re-advertises the namespace over a path that now flows through us. @@ -7220,7 +7630,7 @@ mod tests { ); // That supersedes the advertisement it repeats, so the old route is retired. - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "the superseded route must not stay attached" @@ -7238,8 +7648,10 @@ mod tests { .encode(&ietf::PublishNamespaceUpdate { // Each update consumes a request id of the peer's parity. request_id: RequestId(3 + 2 * i as u64), + namespace_request_id: None, hops: Some(advert.hops.clone()), cost: Some(advert.cost), + authorization_token: None, }) .await .unwrap(); @@ -7288,7 +7700,7 @@ mod tests { let path = crate::Path::new("room/host").to_owned(); let advert = subscriber.route(Some(attached), peer).expect("route"); - subscriber.start_announce(path, advert).unwrap(); + subscriber.start_announce(path, advert, Admit::Session).unwrap(); assert!(routed_now(&consumer, "room/host").is_some(), "attached to start with"); let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); @@ -7373,6 +7785,7 @@ mod tests { Some(clean), peer, &mut attached, + None, ) .await .expect_err("a repeated update ID must close the session"); @@ -7403,6 +7816,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..100 { @@ -7449,6 +7863,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); // Both updates apply, then the loop parks on the exhausted script. The @@ -7468,33 +7883,119 @@ mod tests { ); } - /// The expected update: a relay that started carrying the namespace reprices it to - /// 0. REQUEST_UPDATE keeps an omitted parameter, so the 0 arrives explicit and alone, - /// lands on the path already held, and is answered REQUEST_OK. + /// A token-authorized announce re-attaching after a reflection reuses the slot its grant + /// still holds: with a cap of one, taking a second would close the session. #[moq_net_sim::test] - async fn an_explicit_zero_reprices_the_held_path() { - const VERSION: Version = Version::Draft19; + async fn a_token_announce_reattaches_on_its_own_slot() { let self_origin = crate::Hop::new(5).unwrap(); - // A free link, so the route cost is exactly what the peer advertised. - let peer = cluster::Peer { - hop: Some(crate::Hop::new(9).unwrap()), - cost: Some(0), - }; - let held = cluster::Advert { - hops: hop_path(&[7, 9]), - cost: 4, + let peer = peer_9(); + let (clean, looped) = clean_and_looped(); + let script = publish_namespace_updates(&[looped, clean.clone()]).await; + let session = crate::lite::test_transport::ScriptedSession::new(script); + let log = session.log.clone(); + let (origin, driver) = crate::origin::Producer::new(crate::origin::Config::new(self_origin)); + std::mem::forget(driver); + let consumer = origin.consume(); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session.clone(), + origin, + Control::new(None, true), + None, + peer::PeerSetup::default(), + self_origin, + None, + Version::Draft19, + tasks, + Default::default(), + ); + subscriber.announces = crate::session::Slots::new(1); + + // Admitted by its own token, which holds the one slot for the request's life. + let path = crate::Path::new("room/host").to_owned(); + let hold: crate::auth::Hold = std::sync::Arc::new(subscriber.announces.acquire().unwrap()); + let auth = crate::auth::Handle::new(true); + let mut requests = auth.requests().unwrap(); + let token = crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff], }; + let verdict = auth.verify_request(token, Some(hold.clone())); + let _issued = requests.next().await.unwrap().accept(crate::auth::Grant::all()); + let answered = verdict.grant().await; + let grant = crate::auth::RequestGrant::new( + &subscriber.runtime, + verdict, + answered, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + Some(hold.clone()), + ) + .unwrap(); + let advert = subscriber.route(Some(&clean), &peer).expect("route"); + subscriber + .start_announce(path.clone(), advert, Admit::Token(Some(hold))) + .unwrap(); - let script = { - let log = crate::lite::test_transport::Log::default(); - let mut writer = - crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); - writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); - writer + let mut stream = Stream::open(&mut session.clone(), Version::Draft19).await.unwrap(); + let mut attached = true; + { + let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( + &mut stream, + &path, + Some(clean.clone()), + peer, + &mut attached, + Some(grant), + )); + for _ in 0..20 { + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the re-attach must not end the announce" + ); + settle().await; + } + } + + assert!(attached, "the clean path must re-attach"); + assert!( + routed_now(&consumer, "room/host").is_some(), + "the namespace is routable again" + ); + assert!(log.closes().is_empty(), "the session stays up: {:?}", log.closes()); + } + + /// The expected update: a relay that started carrying the namespace reprices it to + /// 0. REQUEST_UPDATE keeps an omitted parameter, so the 0 arrives explicit and alone, + /// lands on the path already held, and is answered REQUEST_OK. + #[moq_net_sim::test] + async fn an_explicit_zero_reprices_the_held_path() { + const VERSION: Version = Version::Draft19; + let self_origin = crate::Hop::new(5).unwrap(); + // A free link, so the route cost is exactly what the peer advertised. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + let held = cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }; + + let script = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer .encode(&ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), + authorization_token: None, }) .await .unwrap(); @@ -7516,6 +8017,7 @@ mod tests { Some(held.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!(futures::poll!(run.as_mut()).is_pending(), "the stream stays open"); @@ -7564,6 +8066,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { @@ -7575,11 +8078,15 @@ mod tests { } assert!( - matches!(result, Some(Ok(()))), + matches!(result, Some(Ok(Withdraw::Reject(..)))), "a refused update ends the stream cleanly, got {result:?}" ); assert!(attached, "the caller releases the route it attached"); - assert_eq!(replies(&log, ietf::RequestError::ID), 1, "REQUEST_ERROR went out"); + assert_eq!( + replies(&log, ietf::RequestError::ID), + 0, + "the caller answers once the route is released" + ); assert_eq!(replies(&log, ietf::RequestOk::ID), 0); } @@ -7610,6 +8117,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!( @@ -7650,6 +8158,7 @@ mod tests { cost: 0, ..clean.clone() }), + authorization_token: None, }) .await .unwrap(); @@ -7668,6 +8177,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); let mut result = None; for _ in 0..20 { @@ -7696,7 +8206,7 @@ mod tests { for path in ["room/a", "room/b"] { let path = crate::Path::new(path).to_owned(); let advert = subscriber.route(None, &peer).expect("route"); - subscriber.start_announce(path.clone(), advert).unwrap(); + subscriber.start_announce(path.clone(), advert, Admit::Session).unwrap(); live.insert(path); } assert!(routed_now(&consumer, "room/a").is_some()); @@ -7704,7 +8214,7 @@ mod tests { // What the stream's exit path does with whatever it still holds. for path in live { - subscriber.stop_announce(path).unwrap(); + subscriber.stop_announce(path, false).unwrap(); } assert!(routed_now(&consumer, "room/a").is_none(), "room/a leaked a refcount"); @@ -7807,6 +8317,1758 @@ mod tests { ); } } + + /// A subscriber whose session grant covers only `other`, with an app auth acceptor + /// wired in, so a PUBLISH_NAMESPACE for `room/alice` falls to its request token. The + /// subscribe stream's reader is scripted with `first_script` (a REQUEST_UPDATE, for the + /// renewal test). Returns the presented credential to keep it alive. + fn auth_announce_harness( + version: Version, + first_script: Vec, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + auth_announce_harness_on( + version, + crate::lite::test_transport::ScriptedSession::per_stream(vec![first_script]), + ) + } + + /// [`auth_announce_harness`] over a given session, such as one that finishes the stream + /// after its script. + fn auth_announce_harness_on( + version: Version, + session: S, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + S, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + // We let the peer announce only "other". + auth.authorize(&crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }); + assert!( + !auth.within_limit(crate::auth::Direction::Subscribe, "room/alice"), + "the limit must not cover the announced path" + ); + + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session.clone(), + origin, + // The announcing peer is the server here, so its update IDs are odd. + Control::new(None, true), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, cred, consumer, session) + } + + /// A grant to publish everything, lapsing in `secs` (or never), on the subscriber's clock: + /// what an announcing peer's token must carry. + fn publish_grant_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: secs.map(|s| runtime.now().checked_add(std::time::Duration::from_secs(s)).unwrap()), + } + } + /// An out-of-band request token, the only kind a session verifies. + /// A request token whose Token Type takes two varint bytes. + fn announce_token() -> crate::ietf::token::RequestToken { + crate::ietf::token::RequestToken(crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff], + }) + } + + fn token_publish_namespace() -> ietf::PublishNamespace<'static> { + ietf::PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("room/alice"), + cluster: None, + authorization_token: Some(announce_token()), + } + } + + /// One REQUEST_UPDATE on the announce stream carrying a fresh token, framed as the peer + /// sends it. + async fn publish_namespace_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + namespace_request_id: (version == Version::Draft16).then_some(RequestId(1)), + hops: None, + cost: None, + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// One REQUEST_UPDATE on the announce stream carrying both a fresh token and new cluster + /// parameters (HOP_PATH/ROUTE_COST), framed as the peer sends it. + async fn publish_namespace_update_with_token_and_cluster(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + namespace_request_id: None, + hops: Some(hop_path(&[7, 9])), + cost: Some(0), + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A request token on a PUBLISH_NAMESPACE the session grant does not cover authorizes + /// the announce: the subscriber verifies it through the acceptor and attaches the route. + #[moq_net_sim::test] + async fn a_publish_namespace_token_authorizes_an_uncovered_announce() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + held.push(request.accept(crate::auth::Grant::all())); + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut announced = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + announced = true; + break; + } + settle().await; + } + assert!(announced, "a valid request token must authorize the announce"); + } + + /// An announce's grant ending while its first answer waits on a peer that stopped + /// reading retracts the route at once, not once the answer drains: the peer is told + /// only afterwards, and on drafts 14-16 with a PUBLISH_NAMESPACE_CANCEL after the OK. + #[moq_net_sim::test] + async fn a_revoked_token_retracts_an_announce_whose_ok_is_blocked() { + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft22, + ] { + let gate = kio::Producer::new(false); + let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(version, session); + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let accepted = async { requests.next().await.expect("a request") }; + let request = match futures::future::select(std::pin::pin!(accepted), run.as_mut()).await { + futures::future::Either::Left((request, _)) => request, + futures::future::Either::Right((res, _)) => panic!("{version}: ended unverified: {res:?}"), + }; + let enforced = request.accept(crate::auth::Grant::all()); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: ended early"); + settle().await; + } + assert!(routed_now(&consumer, "room/alice").is_some(), "{version}: never routed"); + assert!( + session.log.writes.lock().unwrap().is_empty(), + "{version}: the OK was not held" + ); + + enforced.revoke(crate::SessionError::Unauthorized, "revoked"); + for _ in 0..10 { + assert!( + futures::poll!(run.as_mut()).is_pending(), + "{version}: told a peer that cannot read" + ); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_none(), + "{version}: routed until the peer could be told" + ); + + let Ok(mut open) = gate.write() else { + panic!("request stream gate closed"); + }; + *open = true; + drop(open); + let _ = moq_net_sim::timeout(std::time::Duration::from_secs(1), run.as_mut()) + .await + .unwrap_or_else(|_| panic!("{version}: outlived its grant")); + + let bytes: Vec = session + .log + .trail() + .into_iter() + .filter_map(|(_, sent)| match sent { + crate::lite::test_transport::Sent::Write(buf) => Some(buf), + _ => None, + }) + .flatten() + .collect(); + let mut sent = Vec::new(); + let mut rest = bytes.as_slice(); + while !rest.is_empty() { + sent.push(crate::coding::decode_varint(&mut rest, version).unwrap()); + let len = usize::from(u16::from_be_bytes([rest[0], rest[1]])); + rest = &rest[2 + len..]; + } + let expected = match version { + Version::Draft14 => vec![ietf::PublishNamespaceOk::ID, ietf::PublishNamespaceCancel::ID], + Version::Draft15 | Version::Draft16 => vec![ietf::RequestOk::ID, ietf::PublishNamespaceCancel::ID], + _ => vec![ietf::RequestOk::ID], + }; + assert_eq!(sent, expected, "{version}"); + assert!(session.log.closes().is_empty(), "{version}: the session stays up"); + } + } + + /// On drafts 14-16 the withdrawal arrives as a message before the FIN: a + /// PUBLISH_NAMESPACE_DONE waiting behind an unanswered verification ends the announce at + /// once, giving its slot back before the application ever takes the token. + #[moq_net_sim::test] + async fn a_legacy_withdrawal_during_verification_ends_the_announce() { + const VERSION: Version = Version::Draft16; + let done = { + let log = crate::lite::test_transport::Log::default(); + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION) + .encode_message(&ietf::PublishNamespaceDone { + track_namespace: crate::Path::new("room/alice"), + request_id: RequestId(1), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + // The application never takes the token, so only the withdrawal can end the wait. + let (mut subscriber, _requests, _cred, consumer, session) = auth_announce_harness(VERSION, done); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..100 { + if let Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert!( + matches!(result, Some(Ok(()))), + "a withdrawal during verification ends the announce: {result:?}" + ); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "and it was never attached" + ); + assert!(session.log.closes().is_empty(), "the session stays up"); + } + + /// A refused request token is answered UNAUTHORIZED and the announce is not attached; the + /// session is untouched. + #[moq_net_sim::test] + async fn a_refused_publish_namespace_token_is_not_announced() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let request = requests.next().await.expect("a request"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused announce ends its own stream"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not attach the route" + ); + } + + /// What the peer's AUTH_OK grants us never authorizes what the peer asks of us: with a + /// covering peer grant and no limit, the application still verifies the announce's token, + /// and its refusal refuses the announce. + #[moq_net_sim::test] + async fn a_covering_peer_grant_never_skips_the_verifier() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(true); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted(0, crate::auth::Grant::all()); + let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert_eq!( + consulted.load(std::sync::atomic::Ordering::Relaxed), + 1, + "the verifier ran" + ); + assert!(ended, "the refused announce ends"); + assert!(routed_now(&consumer, "room/alice").is_none(), "and is never attached"); + } + + /// Every token is verified, even on an announce the limit already covers, so a verified + /// one stands outside the limit: narrowing the limit away from it leaves it attached, as the + /// publisher, which counts it token-backed, expects. Admitted on the limit instead, the + /// narrowing would withhold a route the publisher still believes its token backs. + #[moq_net_sim::test] + async fn a_covering_limit_still_verifies_the_token() { + const VERSION: Version = Version::Draft18; + let limit = |subtree: &str| crate::auth::Grant { + publish: crate::Pattern::subtree(subtree).unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + let auth = crate::auth::Handle::new(false); + auth.authorize(&limit("room")); + let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _held = request.accept(crate::auth::Grant::all()); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!(routed, "the verified token admits the announce"); + assert_eq!( + consulted.load(std::sync::atomic::Ordering::Relaxed), + 1, + "the verifier ran though the limit covers the announce" + ); + + auth.authorize(&limit("other")); + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended"); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_some(), + "narrowing the limit withheld the token-verified announce" + ); + } + + /// A token-authorized announce stands outside the limit while it lasts: a narrower limit + /// leaves it attached, and only its own end retracts it. + #[moq_net_sim::test] + async fn a_token_authorized_announce_is_exempt_from_the_limit() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::default()); + let (mut subscriber, mut requests, consumer, session) = announce_harness_with(VERSION, auth.clone()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request"); + let _held = request.accept(crate::auth::Grant::all()); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!(routed, "the token admits the announce outside the limit"); + + auth.authorize(&crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }); + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended"); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_some(), + "a narrower limit leaves the token-authorized announce attached" + ); + } + + /// A token-authorized announce authorizes the tracks subscribed under it, outside the + /// limit, and only while its token lasts. + #[moq_net_sim::test] + async fn a_token_authorized_announce_admits_its_subscriptions() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::default()); + // The announce's peer says nothing; the subscription's accepts it. + let subscribe_ok = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::SubscribeOk::ID).await.unwrap(); + let ok = ietf::SubscribeOk { + request_id: None, + track_alias: 7, + largest: None, + properties: Default::default(), + }; + writer.encode(&ok).await.unwrap(); + log.writes.lock().unwrap().clone() + }; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), subscribe_ok]); + let (mut subscriber, mut requests, consumer, session) = announce_harness_on(VERSION, auth, session); + // Serve the route, which the harness leaves undriven. + let (tasks, mut task_set) = crate::util::TaskSet::new(); + subscriber.tasks = tasks; + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut next = std::pin::pin!(requests.next()); + let mut issued = None; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if let Poll::Ready(request) = futures::poll!(next.as_mut()) { + issued = Some(request.expect("a request").accept(crate::auth::Grant::all())); + break; + } + settle().await; + } + let issued = issued.expect("the announce never presented its token"); + + let mut routing = std::pin::pin!(consumer.routed_broadcast("room/alice")); + let mut broadcast = None; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + let _ = task_set.poll(&kio::Waiter::noop()); + if let Poll::Ready(routed) = futures::poll!(routing.as_mut()) { + broadcast = Some(routed.expect("the announce routes the broadcast")); + break; + } + settle().await; + } + let broadcast = broadcast.expect("the broadcast never routed"); + + let track = broadcast.track("video").unwrap(); + let mut subscribing = std::pin::pin!(track.subscribe(None)); + let mut subscribed = None; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + let _ = task_set.poll(&kio::Waiter::noop()); + if let Poll::Ready(res) = futures::poll!(subscribing.as_mut()) { + subscribed = Some(res.expect("the token authorizes the subscription")); + break; + } + settle().await; + } + let mut subscribed = subscribed.expect("the subscription never started"); + + issued.revoke(crate::SessionError::Unauthorized, "revoked"); + let mut ended = None; + let mut retracted = false; + for _ in 0..500 { + if !retracted { + retracted = futures::poll!(run.as_mut()).is_ready(); + } + let _ = task_set.poll(&kio::Waiter::noop()); + if let Poll::Ready(res) = subscribed.poll_recv_group(&kio::Waiter::noop()) { + ended = Some(res.err()); + break; + } + settle().await; + } + assert!( + matches!(ended, Some(Some(Error::Unauthorized))), + "the subscription must end with its token: {ended:?}" + ); + } + + /// A REQUEST_UPDATE the acceptor renews keeps a token-authorized announce alive past the + /// old grant's expiry: the subscriber re-verifies the token off the announce stream and + /// re-arms the deadline. + #[moq_net_sim::test] + async fn a_publish_namespace_renewal_extends_past_the_old_expiry() { + for version in [Version::Draft16, Version::Draft18] { + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(version, publish_namespace_update_with_token(version).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.expect("a request"); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + // Let the loop apply the renewal it read. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the announce + // stays attached. + moq_net_sim::advance(std::time::Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "renewal did not extend the announce" + ); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_some(), + "the renewed announce must still be attached" + ); + } + } + + /// A lapsed grant retracts its announce at once, even while the withdrawal waits on a peer + /// that never acknowledges the closed stream. + #[moq_net_sim::test] + async fn a_lapsed_announce_grant_retracts_before_the_peer_reads() { + const VERSION: Version = Version::Draft18; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]).with_unacked_fin(); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let request = requests.next().await.expect("a request"); + let _held = request.accept(publish_grant_expiring(&rt, Some(60))); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!(routed, "the token must authorize the announce"); + + moq_net_sim::advance(std::time::Duration::from_secs(120)).await; + let mut retracted = false; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the withdrawal waits on the peer's acknowledgement" + ); + if routed_now(&consumer, "room/alice").is_none() { + retracted = true; + break; + } + settle().await; + } + assert!( + retracted, + "a lapsed grant must retract the route without waiting on the peer" + ); + } + + /// An update repeating its token withdraws a token-authorized announce, retracting the route + /// at once even while the withdrawal waits on a peer that never acknowledges it. + #[moq_net_sim::test] + async fn a_repeated_token_update_retracts_before_the_peer_reads() { + const VERSION: Version = Version::Draft18; + const TOKEN: &[u8] = &[0x03, 0x00, 0xAA]; + // REQUEST_UPDATE 3 carrying the same AUTHORIZATION TOKEN twice: a count, then two + // delta-encoded token parameters. + let mut body = vec![3, 2, 0x03, TOKEN.len() as u8]; + body.extend(TOKEN); + body.extend([0, TOKEN.len() as u8]); + body.extend(TOKEN); + let mut script = vec![ietf::PublishNamespaceUpdate::ID as u8, 0, body.len() as u8]; + script.extend(body); + + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]).with_unacked_fin(); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let request = requests.next().await.expect("a request"); + let _held = request.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the withdrawal waits on the peer's acknowledgement" + ); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a repeated token must retract the route without waiting on the peer" + ); + assert!(session.log.closes().is_empty(), "the session stays up"); + } + + /// Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused PUBLISH_NAMESPACE + /// renewal ends only that announce at once, by closing its stream (and cancelling it on + /// draft-16), not by waiting for the old 60s grant to lapse. The session stays up. + #[moq_net_sim::test] + async fn a_refused_publish_namespace_renewal_ends_the_announce() { + for version in [Version::Draft16, Version::Draft18] { + let (mut subscriber, mut requests, _cred, _consumer, session) = + auth_announce_harness(version, publish_namespace_update_with_token(version).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + // The first grant lasts 60s; the renewal is refused. + let first = requests.next().await.expect("a request"); + let _issued = first.accept(publish_grant_expiring(&rt, Some(60))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let renewal = requests.next().await.expect("a renewal"); + renewal.reject(crate::SessionError::Unauthorized, "no"); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + // No time is advanced: the refusal must end the announce on its own, not by letting the + // 60s grant lapse. + let mut result = None; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + assert!( + matches!(result, Some(Ok(()))), + "a refused renewal ends the announce cleanly by closing the stream, got {result:?}" + ); + assert!( + session.log.closes().is_empty(), + "the session must stay up: {:?}", + session.log.closes() + ); + // Draft-16's request outlives its stream, so the refusal cancels it explicitly. + if version == Version::Draft16 { + let cancel = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer + .encode_message(&ietf::PublishNamespaceCancel { + track_namespace: crate::Path::new("room/alice"), + request_id: RequestId(1), + error_code: request::to_code( + &Error::Unauthorized, + request::Kind::PublishNamespace, + version, + ), + reason_phrase: "no".into(), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + let writes = session.log.writes.lock().unwrap().clone(); + assert!( + writes.windows(cancel.len()).any(|window| window == cancel.as_slice()), + "a refused draft-16 renewal sends PUBLISH_NAMESPACE_CANCEL" + ); + } + } + } + + /// Where tokens decide, a token arriving on an update for an announce the limit admitted + /// without one is refused rather than taken unread: it ends only that announce, unverified. + #[moq_net_sim::test] + async fn a_token_on_an_announce_admitted_without_one_is_refused() { + for version in [Version::Draft16, Version::Draft18] { + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(version, publish_namespace_update_with_token(version).await); + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let tokenless = ietf::PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("other/alice"), + cluster: None, + authorization_token: None, + }; + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + tokenless, + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..500 { + if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert!( + matches!(result, Some(Ok(()))), + "{version}: the update's token ends the announce, got {result:?}" + ); + assert!(routed_now(&consumer, "other/alice").is_none(), "{version}"); + assert!( + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "{version}: the token reached the verifier" + ); + assert!(session.log.closes().is_empty(), "{version}: the session closed"); + } + } + + /// A REQUEST_UPDATE carrying both a fresh token and cluster parameters applies both: the + /// renewal re-arms the grant and the HOP_PATH/ROUTE_COST re-route the advertisement, + /// answered with the renewal's single response. Before the fix the token branch + /// short-circuited the loop and the cluster parameters on the same update were dropped. + #[moq_net_sim::test] + async fn an_update_applies_both_a_renewal_and_cluster_params() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token_and_cluster(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives already routed at cost 4; the update re-routes it to cost 0. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + let acceptor = { + let rt = rt.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.expect("a request"); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + let mut rerouted = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.value() == 0) { + rerouted = true; + break; + } + settle().await; + } + assert!( + rerouted, + "the update's cluster parameters must re-route the announce (cost 4 to 0), not be dropped by the renewal" + ); + } + + /// One PUBLISH_NAMESPACE REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID, + /// optionally changing the HOP_PATH and/or ROUTE_COST, framed as the peer sends it. + async fn publish_namespace_update_token_rid( + version: Version, + rid: u64, + hops: Option, + cost: Option, + ) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(rid), + namespace_request_id: None, + hops, + cost, + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two announce renewals that arrive while a first renewal's verdict is still pending are + /// each verified, and each one's cluster delta is applied, not collapsed. The middle update + /// (0x05) changes only ROUTE_COST (4 to 1) and the last (0x07) only HOP_PATH, so the final + /// cost shows the middle delta survived; the single slot this replaced dropped 0x05, + /// leaving the cost at the initial 4 and never verifying its token. + #[moq_net_sim::test] + async fn two_announce_renewals_buffered_behind_a_pending_verdict_each_apply_and_answer() { + const VERSION: Version = Version::Draft18; + + let mut script = publish_namespace_update_token_rid(VERSION, 0x03, None, None).await; + script.extend(publish_namespace_update_token_rid(VERSION, 0x05, None, Some(1)).await); + script.extend(publish_namespace_update_token_rid(VERSION, 0x07, Some(hop_path(&[7, 11])), None).await); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives routed at cost 4; the buffered updates re-route it. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + // Hold the first renewal (0x03) so the next two (0x05, 0x07) are read and buffered while + // its verdict is pending: the window the single slot used to collapse. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let rt = rt.clone(); + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.expect("a request"); + let mut held = vec![first.accept(publish_grant_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.expect("a renewal"); + release.notified().await; + held.push(a.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + loop { + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + settle().await; + } + release.notify_one(); + let mut both = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 + && routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.value() == 1) + { + both = true; + break; + } + settle().await; + } + assert!( + both, + "both buffered updates must each be verified (4 total) and each delta applied: the \ + middle update's cost (1), not the single-slot survivor's initial 4" + ); + } + + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that on + /// an announce stream broke the negotiated limit: draft-19 section 10.3.1.7 closes the session + /// with TOO_MANY_REQUEST_UPDATES. run_publish_namespace_updates must close explicitly (the + /// dispatcher does not close on an Error::Session), and surface that error too. + #[moq_net_sim::test] + async fn announce_renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; + + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it. + let last = 0x41 + 2 * request_update::MAX_REQUEST_UPDATES; + let mut script = Vec::new(); + for rid in (0x41..=last).step_by(2) { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } + let Some(Err(err)) = result else { + panic!("flooding past the advertised limit must end the announce with an error: {result:?}"); + }; + assert_eq!( + SessionError::from(&err), + SessionError::TooManyRequestUpdates, + "the flood must surface TOO_MANY_REQUEST_UPDATES" + ); + assert!( + session + .log + .closes() + .iter() + .any(|close| close.0 == SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + session.log.closes() + ); + } + + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the announce + /// keeps running and the session stays up. + #[moq_net_sim::test] + async fn announce_renewals_at_the_advertised_limit_keep_the_announce() { + const VERSION: Version = Version::Draft19; + + let last = 0x41 + 2 * (request_update::MAX_REQUEST_UPDATES - 1); + let mut script = Vec::new(); + for rid in (0x41..=last).step_by(2) { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce must stay up at exactly the advertised limit" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "no session close at the limit: {:?}", + session.log.closes() + ); + } + + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the announce nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[moq_net_sim::test] + async fn older_drafts_do_not_strand_an_announce_flood() { + const VERSION: Version = Version::Draft18; + + let mut script = Vec::new(); + for rid in (0x41..=0x81u64).step_by(2) { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the announce" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "draft-18 flood must not close the session" + ); + } + + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one announce by finishing the stream, never the session. + #[moq_net_sim::test] + async fn an_older_draft_announce_flood_past_the_guard_ends_the_announce() { + const VERSION: Version = Version::Draft18; + + let last = 0x41 + 2 * (request_update::UNNEGOTIATED_GUARD as u64 + 1); + let mut script = Vec::new(); + for rid in (0x41..=last).step_by(2) { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } + assert!( + matches!(result, Some(Ok(()))), + "a flood past the guard ends the announce with Ok: {result:?}" + ); + assert!( + session.log.closes().is_empty(), + "the guard ends the announce, not the session" + ); + } + + /// A flood past the guard retracts the announce at once, even while the withdrawal waits on + /// a peer that never acknowledges the finished stream. + #[moq_net_sim::test] + async fn a_flood_past_the_guard_retracts_before_the_peer_reads() { + const VERSION: Version = Version::Draft18; + + let last = 0x41 + 2 * (request_update::UNNEGOTIATED_GUARD as u64 + 1); + let mut script = Vec::new(); + for rid in (0x41..=last).step_by(2) { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]).with_unacked_fin(); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + // The announce attaches, then the flood overflows the guard, possibly within one poll. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the withdrawal waits on the peer's acknowledgement" + ); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a flood past the guard must retract the route without waiting on the peer" + ); + assert!( + session.log.closes().is_empty(), + "the guard ends the announce, not the session" + ); + } + + /// Like [`auth_announce_harness`] but the session never negotiated the MoQ Auth extension, + /// so its union is `None` forever, and the application set `limit`, if any. + fn auth_announce_harness_no_ext( + version: Version, + limit: Option, + ) -> ( + Subscriber, + crate::auth::Requests, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let auth = crate::auth::Handle::new(false); + if let Some(limit) = &limit { + auth.authorize(limit); + } + announce_harness_with(version, auth) + } + + /// An announce harness over `auth`, set up by the caller, taking its requests. + fn announce_harness_with( + version: Version, + auth: crate::auth::Handle, + ) -> ( + Subscriber, + crate::auth::Requests, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + announce_harness_on(version, auth, session) + } + + /// An announce harness over `auth` and `session`, both set up by the caller. + fn announce_harness_on( + version: Version, + auth: crate::auth::Handle, + session: crate::lite::test_transport::ScriptedSession, + ) -> ( + Subscriber, + crate::auth::Requests, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let requests = auth.requests().unwrap(); + + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session.clone(), + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, consumer, session) + } + + /// On a session without the AUTH extension, a token-bearing PUBLISH_NAMESPACE the limit + /// does not cover is verified by the application. Admitted on a covering grant; refused + /// UNAUTHORIZED (not admitted) on refusal. This is the standard moq-transport peer shape. + #[moq_net_sim::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + + // Accepted: the acceptor is consulted (proving the token path, not the permissive + // default, which the origin model would also route) and its grant admits the announce. + { + let (mut subscriber, mut requests, consumer, session) = + auth_announce_harness_no_ext(VERSION, Some(crate::auth::Grant::default())); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request reaches the acceptor"); + consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!( + routed, + "a token on a no-auth session must be verified and admitted, not ignored" + ); + assert!( + consulted.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the token must reach the acceptor, not be admitted by the permissive default" + ); + } + + // Refused: not admitted by the permissive default. + { + let (mut subscriber, mut requests, consumer, session) = + auth_announce_harness_no_ext(VERSION, Some(crate::auth::Grant::default())); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused token ends the announce"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not be admitted by the permissive default" + ); + } + } + + /// A peer that ends the announce while a renewal is still being verified ends it here too: + /// with a grant that never expires and an acceptor that never answers the renewal, only + /// the stream closing can end the request, and it must. + #[moq_net_sim::test] + async fn a_closed_announce_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + let session = crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + publish_namespace_update_with_token(VERSION).await, + ]); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the announce was token-authorized" + ); + assert!( + ended, + "closing the stream ends the announce despite the pending renewal" + ); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "the announce is withdrawn" + ); + } + + /// An update queued behind a pending renewal spends its request ID on arrival, so the + /// announce ending before the queue drains leaves no ID the peer could reuse. + #[moq_net_sim::test] + async fn a_queued_update_spends_its_request_id_when_the_announce_ends() { + const VERSION: Version = Version::Draft18; + let queued = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer + .encode(&ietf::PublishNamespaceUpdate { + request_id: RequestId(5), + namespace_request_id: None, + hops: None, + cost: None, + authorization_token: None, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + let session = crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + [publish_namespace_update_with_token(VERSION).await, queued].concat(), + ]); + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness_on(VERSION, session); + let control = subscriber.control.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + let _never_answered = requests.next().await.expect("a renewal"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "closing the stream ends the announce"); + assert!( + control.accept(RequestId(5)).is_err(), + "the queued update's request ID was left unspent" + ); + } + + /// An update queued behind a pending renewal is decoded on arrival, so a session-fatal one + /// still closes the session when the peer withdraws before the queue drains. + #[moq_net_sim::test] + async fn a_queued_malformed_update_is_fatal_despite_a_withdrawal() { + const VERSION: Version = Version::Draft18; + let queued = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer + .encode(&ietf::PublishNamespaceUpdate { + request_id: RequestId(5), + namespace_request_id: None, + hops: None, + cost: None, + authorization_token: Some(crate::ietf::token::RequestToken(crate::setup::Token { + kind: 0, + value: Vec::new(), + })), + }) + .await + .unwrap(); + let bytes = log.writes.lock().unwrap().clone(); + // Rewrite the by-value token (length 2: USE_VALUE, type 0) as a reference to alias 7, + // which was never registered. + let at: Vec<_> = bytes + .windows(3) + .enumerate() + .filter(|(_, w)| *w == [0x02, 0x03, 0x00]) + .collect(); + assert_eq!(at.len(), 1, "the token parameter is found once"); + let at = at[0].0; + [&bytes[..at], &[0x02, 0x02, 0x07], &bytes[at + 3..]].concat() + }; + let session = crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + [publish_namespace_update_with_token(VERSION).await, queued].concat(), + ]); + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness_on(VERSION, session); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + let _never_answered = requests.next().await.expect("a renewal"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = None; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(result) = futures::poll!(run.as_mut()) { + ended = Some(result); + break; + } + settle().await; + } + let Some(result) = ended else { + panic!("closing the stream ends the announce"); + }; + assert!( + matches!( + result, + Err(Error::Decode(DecodeError::Session( + crate::SessionError::UnknownAuthTokenAlias + ))) + ), + "the queued update's unknown alias closes the session: {result:?}" + ); + } + + /// An announcing peer's token grant is checked on its `publish` patterns: a read-only grant + /// does not admit a PUBLISH_NAMESPACE, a write-only one does. + #[moq_net_sim::test] + async fn an_announce_token_needs_a_publish_grant() { + const VERSION: Version = Version::Draft18; + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + for (grant, admitted) in [(read_only, false), (write_only, true)] { + let (mut subscriber, mut requests, consumer, session) = + auth_announce_harness_no_ext(VERSION, Some(crate::auth::Grant::default())); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + let _issued = request.accept(grant); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..300 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + if admitted && routed_now(&consumer, "room/alice").is_some() { + break; + } + settle().await; + } + assert_eq!( + routed_now(&consumer, "room/alice").is_some(), + admitted, + "admitted={admitted}" + ); + assert_eq!(ended, !admitted, "a grant that does not cover the announce refuses it"); + } + } + + /// The additive constraint: a token-LESS PUBLISH_NAMESPACE on a no-auth session (None union) + /// is admitted by the origin model exactly as before; the token path is never entered. + #[moq_net_sim::test] + async fn a_token_less_request_on_a_no_auth_session_is_unchanged() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, _requests, consumer, session) = auth_announce_harness_no_ext(VERSION, None); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let mut msg = token_publish_namespace(); + msg.authorization_token = None; + + let mut run = + std::pin::pin!(subscriber.run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None,)); + let mut routed = false; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!( + routed, + "a token-less announce is admitted by the origin model as before" + ); + } + + /// Holds an acceptor until the test releases it. + #[derive(Clone, Default)] + struct Release(kio::Shared); + + impl Release { + fn notify_one(&self) { + *self.0.lock() = true; + } + + async fn notified(&self) { + kio::wait(|waiter| { + self.0 + .poll(waiter, |released| match **released { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + }) + .await + } + } } /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that @@ -8611,6 +10873,7 @@ mod stitch_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } .encode_msg(&mut Encoder::new(&mut body, egress.into()), egress) .unwrap(); @@ -9139,6 +11402,7 @@ mod stitch_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let mut body = Vec::new(); subscribe @@ -10746,7 +13010,7 @@ mod joining_fetch_tests { let mut serving_subscriber = subscriber.clone(); let serving = moq_net_sim::spawn(async move { serving_subscriber - .run_subscribe(Path::new("broadcast"), dynamic, request) + .run_subscribe(Path::new("broadcast"), dynamic, None, request) .await; }); diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 9dd600cb31..0860c517b6 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -47,9 +47,16 @@ pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> R /// rules except that a registration overflows the cache instead of falling back to a value. /// /// The parameter may repeat, so a request decodes it as a `Vec` and every instance is checked. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct RequestToken(pub Token); +// A request message is logged with `Debug`, so the credential shows only its type and length. +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestToken(kind={}, <{} bytes>)", self.0.kind, self.0.value.len()) + } +} + impl Param for RequestToken { fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { w.bytes(&encode(&self.0, version)?) @@ -61,21 +68,42 @@ impl Param for RequestToken { } } -/// Refuse a request whose tokens repeat one another. +/// The one token a request carries, if any. /// /// Section 8.9 lets a token repeat only with a different type or value, after resolving -/// aliases, of which we accept none. Checked once every parameter decoded, so a token that -/// closes the session wins over a repeat. -pub fn unique(tokens: &[RequestToken]) -> Result<(), DecodeError> { +/// aliases, of which we accept none, so a repeat is [`DecodeError::MalformedAuthToken`]. +/// Distinct tokens are legal, but authorizing on one would silently ignore the rest, so more +/// than one is refused as [`DecodeError::MultipleAuthTokens`]. Checked once every parameter +/// decoded, so a token that closes the session wins over either. +pub fn single(tokens: Vec) -> Result, DecodeError> { for (i, token) in tokens.iter().enumerate() { if tokens[..i].contains(token) { return Err(DecodeError::MalformedAuthToken); } } - Ok(()) + let mut tokens = tokens.into_iter(); + match (tokens.next(), tokens.next()) { + (token, None) => Ok(token), + _ => Err(DecodeError::MultipleAuthTokens), + } } -/// Refuse a request that decoded to [`DecodeError::MalformedAuthToken`], ending its stream. +/// Whether a decode error refuses only the request whose tokens caused it, leaving the +/// session alone: a repeat (section 8.9), or more than one token. +pub(super) fn refuses_request(err: &DecodeError) -> bool { + matches!(err, DecodeError::MalformedAuthToken | DecodeError::MultipleAuthTokens) +} + +/// The reason phrase for a refusal [`refuses_request`] accepts. +pub(super) fn reason(err: &DecodeError) -> &'static str { + match err { + DecodeError::MultipleAuthTokens => "more than one AUTHORIZATION TOKEN", + _ => "repeated AUTHORIZATION TOKEN", + } +} + +/// Refuse a request whose tokens decoded to `err`, one [`refuses_request`] accepts, ending +/// its stream. /// /// Section 8.9 rejects the message rather than the session. Every request starts with its /// Request ID, so it is read from the undecoded `body`. @@ -84,6 +112,7 @@ pub(super) async fn reject( mut stream: Stream, kind: request::Kind, body: super::Body, + err: DecodeError, version: Version, ) -> Result<(), Error> { let request_id = RequestId::decode(&mut body.decoder(version), version)?; @@ -97,14 +126,15 @@ pub(super) async fn reject( (Version::Draft14, request::Kind::TrackStatus) => super::TRACK_STATUS_ERROR_14, _ => super::RequestError::ID, }; - let err = Error::Decode(DecodeError::MalformedAuthToken); + let reason = reason(&err); + let err = Error::Decode(err); stream.writer.varint(id).await?; stream .writer .encode(&super::RequestError { request_id: matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16).then_some(request_id), error_code: request::to_code(&err, kind, version), - reason_phrase: "repeated AUTHORIZATION TOKEN".into(), + reason_phrase: reason.into(), retry_interval: 0, }) .await?; @@ -171,18 +201,24 @@ mod tests { use super::*; use crate::coding::{Decode, Encode}; - /// Section 8.9: tokens may repeat only with a different type or value. + /// Section 8.9: tokens may repeat only with a different type or value, which is malformed + /// over any other refusal. Distinct tokens are legal but refused: a request carries one. #[test] - fn a_repeated_token_is_malformed() { + fn a_request_carries_at_most_one_token() { let token = |kind, value: &[u8]| { RequestToken(Token { kind, value: value.to_vec(), }) }; - assert!(unique(&[token(0, b"a"), token(1, b"a"), token(0, b"b")]).is_ok()); + assert_eq!(single(Vec::new()).unwrap(), None); + assert_eq!(single(vec![token(0, b"a")]).unwrap(), Some(token(0, b"a"))); + assert!(matches!( + single(vec![token(0, b"a"), token(1, b"a"), token(0, b"b")]), + Err(DecodeError::MultipleAuthTokens) + )); assert!(matches!( - unique(&[token(0, b"a"), token(1, b"a"), token(0, b"a")]), + single(vec![token(0, b"a"), token(1, b"a"), token(0, b"a")]), Err(DecodeError::MalformedAuthToken) )); } @@ -270,6 +306,21 @@ mod tests { } } + /// A request's credential shows only its type and length in a message's `Debug`. + #[test] + fn a_request_token_debug_hides_the_value() { + let debug = format!( + "{:?}", + RequestToken(Token { + kind: 0, + value: b"s3cr3t-jwt".to_vec(), + }) + ); + assert!(!debug.contains("s3cr3t"), "{debug}"); + assert!(!debug.contains("115"), "{debug}"); + assert!(debug.contains("<10 bytes>"), "{debug}"); + } + #[test] fn absent_is_none() { for version in VERSIONS { diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index 70ed605f71..3ac1d465a3 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -100,6 +100,7 @@ mod tests { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }; let subscribe_ok = SubscribeOk { @@ -171,6 +172,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; // Draft-20 spells Next Object as a Length of 2 and two zero fields; draft-22 as type 0x05. diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 77a24dfa8c..ab6cc768a3 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -660,6 +660,8 @@ pub struct ScriptedSession { /// in order. See [`Self::with_incoming_unis`]. incoming_unis: Arc>>>, incoming_bidis: Arc>>>, + /// Set by [`Self::with_unacked_fin`]. + unacked_fin: bool, datagram_size: usize, } @@ -685,10 +687,17 @@ impl ScriptedSession { park: kio::Park::default(), incoming_unis: Arc::new(Mutex::new(std::collections::VecDeque::new())), incoming_bidis: Arc::new(Mutex::new(std::collections::VecDeque::new())), + unacked_fin: false, datagram_size: 0, } } + /// Never acknowledge an opened stream's FIN, like a peer that stopped reading. + pub fn with_unacked_fin(mut self) -> Self { + self.unacked_fin = true; + self + } + /// Have the peer open one unidirectional stream per script, in order, each replaying /// its bytes and then going quiet (or reporting EOF, on an `eof` session). /// @@ -813,8 +822,10 @@ impl poll::Session for ScriptedSession { Some(queue) => Arc::new(Mutex::new(queue.lock().unwrap().pop_front().unwrap_or_default())), None => self.script.clone(), }; + let mut send = SinkSend::new(self.log.clone()); + send.unacked_fin = self.unacked_fin; Poll::Ready(Ok(( - SinkSend::new(self.log.clone()), + send, ScriptedRecv { script, close: self.close.clone(), diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 5a34ccef58..371e347a16 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1217,6 +1217,53 @@ mod tests { params } + /// An acceptor taken with `Request::auth().requests()` BEFORE `ok()` must govern the + /// session the driver then runs, through the REAL accept path (`accept_request` -> `ok`). + /// The session's own `auth()` (the handle the driver polls) routes a request token to that + /// pre-ok consumer instead of falling to the `Unsupported` default, and granting admits. + /// Both a no-AUTH-extension legacy session (draft-14, `Handle::new(false)`) and the modern + /// uni-SETUP path (draft-18); a QUIC server has no other point to install the verifier. + #[moq_net_sim::test] + async fn a_pre_ok_verifier_governs_the_session() { + let cases = [ + ( + FakeSession::new(ALPN_14, []) + .with_bi(legacy_setup(ietf::Version::Draft14, ietf::Parameters::default())), + ietf::Version::Draft14, + ), + ( + FakeSession::new(ALPN_18, [ietf_setup(ietf::Version::Draft18, Some("room/alice"))]), + ietf::Version::Draft18, + ), + ]; + + for (session, version) in cases { + let request = Server::new() + .accept_request(moq_net_sim::now(), session) + .await + .unwrap_or_else(|e| panic!("accept at {version:?}: {e}")); + + // Install the verifier before ok(), as a QUIC server must. + let mut requests = request.auth().requests().expect("requests() pre-ok"); + let (net_session, _driver) = request.ok().await.unwrap_or_else(|e| panic!("ok at {version:?}: {e}")); + + // The session's own handle is the one the driver polls. Verifying a request token on + // it must reach the pre-ok consumer; if the handle were not shared it would fall to + // the Unsupported default and the consumer would never see this request. + let verdict = net_session.auth().verify_request( + crate::setup::Token { + kind: crate::setup::Token::OUT_OF_BAND, + value: b"jwt".to_vec(), + }, + None, + ); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); + assert_eq!(received.token().as_ref(), b"jwt", "{version:?}"); + let _issued = received.accept(crate::auth::Grant::all()); + assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); + } + } + #[moq_net_sim::test] async fn accept_request_exposes_the_setup_token() { let modern = FakeSession::new( diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 75c9923f09..f560961711 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -138,9 +138,11 @@ impl Refresh { } } + /// The connection credential's grant. moq-net adds every grant issued to an AUTH token + /// itself, and keeps a request token's grant to that request alone. fn authorize(&self) { let mut union = auth::Grant::default(); - for credential in self.credentials.values() { + for credential in self.credentials.values().filter(|credential| credential.initial) { let grant = credential.wire(self.role); union.publish.extend(grant.publish.iter().cloned()); union.subscribe.extend(grant.subscribe.iter().cloned());