From e72d5edbe2ac0b163de4baafbcf0e702046034ab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 10:13:55 -0700 Subject: [PATCH 01/53] feat(auth): a token on a request authorizes that request Port of #4675, squashed from #5148's first seven commits (7277e5df3 through 311a73c8e) and replayed onto main now that #5106 and #5107 have landed. The moq-tokio half is left out here; it moves onto #5282's Connection::auth() handle in a later commit. A SUBSCRIBE or PUBLISH_NAMESPACE is authorized by the session grant first, then by the AUTHORIZATION TOKEN it carries, verified by the app's auth::Handle::requests() acceptor and scoped to that one request, else UNAUTHORIZED. The token's grant never joins the session union, ends with the request, and is renewed in band by an update. A refused renewal ends only that request. Draft-19+ advertises and enforces MAX_REQUEST_UPDATES, closing with TOO_MANY_REQUEST_UPDATES (0x1B). Replaying onto main keeps main's decoder rules: a request repeating an identical token is refused MALFORMED_AUTH_TOKEN, and draft-17+ updates spend their own request ID. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 4 +- drafts/draft-lcurley-moq-lite.md | 4 +- js/net/src/error.test.ts | 1 + js/net/src/error.ts | 2 + rs/moq-net/src/auth.rs | 704 +++- rs/moq-net/src/error.rs | 9 + rs/moq-net/src/fuzz.rs | 1 + rs/moq-net/src/ietf/adapter.rs | 26 +- rs/moq-net/src/ietf/control.rs | 50 + rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/parameters.rs | 6 +- rs/moq-net/src/ietf/publish_namespace.rs | 37 +- rs/moq-net/src/ietf/publisher.rs | 3875 ++++++++++++++++---- rs/moq-net/src/ietf/request_stream.rs | 17 +- rs/moq-net/src/ietf/request_update.rs | 186 + rs/moq-net/src/ietf/session.rs | 125 +- rs/moq-net/src/ietf/subscribe.rs | 132 +- rs/moq-net/src/ietf/subscribe_namespace.rs | 1 + rs/moq-net/src/ietf/subscriber.rs | 1755 ++++++++- rs/moq-net/src/ietf/token.rs | 24 +- rs/moq-net/src/ietf/version.rs | 2 + rs/moq-net/src/lib.rs | 1 + rs/moq-net/src/server.rs | 50 + rs/moq-net/src/session.rs | 3 + rs/moq-net/tests/auth.rs | 535 +++ 25 files changed, 6632 insertions(+), 919 deletions(-) create mode 100644 rs/moq-net/src/ietf/request_update.rs diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 83844db2e6..9a4a6fde0f 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.** 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`. A token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **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 222a7173fa..57d881f070 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. | | ------- | ------------- | ----------- | @@ -1577,7 +1579,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/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/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index cf036f7d98..03134dcee0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -116,6 +116,8 @@ fn earliest(a: Option, b: Option) -> Option { pub(crate) struct State { /// Whether the negotiated version carries AUTH at all. supported: bool, + /// The session speaks moq-lite, whose requests carry no token. + lite: bool, /// Set once the session ends; every waiter resolves with it. closed: Option, next_id: u64, @@ -251,12 +253,61 @@ impl Permit { } } +/// The `AUTHORIZATION TOKEN` this side presents on its own SUBSCRIBE and PUBLISH_NAMESPACE +/// requests (MoQ request-token), shared between a session's [`Handle`] and its driver. +/// +/// The session reads the current value when it first sends a request, and re-presents a +/// changed one on each live request as a REQUEST_UPDATE. The default presents no token, +/// which is byte-identical to a session that never sets one. +#[derive(Clone, Default)] +pub(crate) struct RequestToken { + token: kio::Shared>, +} + +impl RequestToken { + /// A credential presenting `token` (or none) until replaced. + #[cfg(test)] + pub(crate) fn new(token: Option) -> Self { + Self { + token: kio::Shared::new(token), + } + } + + /// Replace the token presented on this session's requests. A live request re-presents + /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. + pub(crate) fn set(&self, token: Option) { + *self.token.lock() = token; + } + + /// The token to present right now, read when a request is first sent. + pub(crate) fn peek(&self) -> Option { + self.token.read().clone() + } + + /// Ready with the current token once it differs from `last`, registering `waiter` + /// otherwise. The send loops park here to re-present a replaced token on their live + /// requests; it reads without advancing `last`, so a poll that loses its turn to + /// another arm is re-offered the change rather than dropping it. + pub(crate) fn poll_changed( + &self, + last: &Option, + waiter: &kio::Waiter, + ) -> Poll> { + let guard = ready_or!(self.token.poll(waiter, |cur| match **cur == *last { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready((*guard).clone()) + } +} + /// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). /// /// Cheap to clone; every clone shares the session's tokens. #[derive(Clone)] pub struct Handle { state: kio::Shared, + request_token: RequestToken, } impl Handle { @@ -267,7 +318,37 @@ impl Handle { supported, ..Default::default() }), + request_token: RequestToken::default(), + } + } + + /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own requests (MoQ + /// request-token), as distinct from a connection credential, which [`add`](Self::add) + /// presents for the whole session. + /// + /// Set it before running the session's driver to present it on the first request. + /// Replacing it later re-presents the new token on every live request as a + /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value + /// again sends nothing. Independent of the MoQ Auth extension. + /// + /// A moq-lite session has no request token to carry it, so it refuses with + /// [`Error::Unsupported`] rather than drop it. + pub fn set_request_token(&self, token: crate::setup::Token) -> Result<()> { + if self.state.read().lite { + return Err(Error::Unsupported); } + self.request_token.set(Some(token)); + Ok(()) + } + + /// The session speaks moq-lite, so [`set_request_token`](Self::set_request_token) refuses. + pub(crate) fn lite(&self) { + self.state.lock().lite = true; + } + + /// The request-token cell the session's publisher and subscriber present from. + pub(crate) fn request_token(&self) -> RequestToken { + self.request_token.clone() } /// Whether this side speaks AUTH: false on a version without it, on a side that does @@ -349,15 +430,61 @@ 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 { - queue.close(); - } + // A request-borne token rides the request message itself, not the AUTH stream, so the + // acceptor answers request tokens even on a session that never negotiated the MoQ Auth + // extension. The queue therefore stays live regardless of `supported`; only session + // token presentation ([`add`](Self::add)) stays [`Error::Unsupported`] without it. state.acceptor = Acceptor::App(queue.clone()); Ok(Requests { queue }) } + /// Verify a token that rode on one request, scoped to that request alone. + /// + /// The token reaches the same acceptor a session token does, tagged with the + /// request's path and kind. The grant the acceptor answers covers only this request, + /// never joins the session union, and ends when the returned [`RequestVerdict`] is + /// dropped, so it lives exactly as long as the request. With no [`requests`] consumer + /// the token cannot be verified, so the verdict is [`Error::Unsupported`] and the + /// caller refuses the request. + pub(crate) fn verify_request( + &self, + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + ) -> RequestVerdict { + match self.acceptor() { + Some(queue) => { + let issue = kio::Shared::::default(); + // A closed or full queue refuses the token. + queue.push(Request::new_request(token, path, kind, issue.clone())); + RequestVerdict { issue: Some(issue) } + } + // No app verifier took the requests: a request token cannot be checked in band. + None => RequestVerdict { issue: None }, + } + } + + /// Admit a request on the token it carries, scoped to that request alone. + /// + /// The acceptor's grant must cover the request, and is then held for the request's life. + /// The error is what the request is refused with: the acceptor's refusal, UNAUTHORIZED for + /// a grant that does not cover it, or NOT_SUPPORTED with no [`requests`](Self::requests) + /// consumer. + pub(crate) async fn admit_request( + &self, + clock: &crate::time::Clock, + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + ) -> Result { + let verdict = self.verify_request(token, path.clone(), kind); + let grant = verdict.grant().await?; + if !kind.covers(&grant, path.as_str()) { + return Err(Error::Unauthorized); + } + Ok(RequestGrant::new(clock, verdict, grant, path, kind)) + } + /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. pub(crate) fn acceptor(&self) -> Option { @@ -531,6 +658,18 @@ impl Handle { limit.is_none_or(|limit| limit.matches(path)) } + /// Whether the union positively covers `path` right now. Unlike [`allows`](Self::allows), a + /// union that is still `None` (no answer yet, or a version without AUTH) does NOT cover: a + /// request presenting a token is verified by it rather than admitted by the permissive + /// default. Only the token-bearing path uses this; the token-less path keeps `allows`. + pub(crate) fn covers(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + state.union.as_ref().is_some_and(|union| match direction { + Direction::Publish => union.publish.matches(path), + Direction::Subscribe => union.subscribe.matches(path), + }) + } + /// The peer turned out not to negotiate AUTH: fail every token as unsupported and /// close the requests, leaving the union unknown. pub(crate) fn unsupported(&self) { @@ -543,9 +682,9 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } + // The request queue stays live: a request-borne token does not ride the AUTH stream, so + // the acceptor keeps answering request tokens without the extension. Only the session + // tokens above end as unsupported. } /// End the session: fail every pending token, end every watch, and close the @@ -848,12 +987,53 @@ impl Drop for Serving { } } +/// Which request a token rode on. A token on a request authorizes that one request, so +/// the acceptor and the session scope its grant to the request's path and kind rather +/// than to the whole session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RequestKind { + /// A SUBSCRIBE: the subscriber 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. + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe => grant.subscribe.matches(path), + Self::PublishNamespace => grant.publish.matches(path), + } + } +} + +/// The request a token rode on, which its grant is scoped to. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Scope { + /// The request's path: the track's namespace, or the namespace announced. + pub path: crate::PathOwned, + /// Which request it is. + pub kind: RequestKind, + /// The token's Token Type (moq-transport section 8.9), naming the verifier it is for. + pub token_kind: u64, +} + /// A token the peer presented, waiting for an answer. /// +/// A token with no [`scope`](Request::scope) is the connection's own credential (or an +/// AUTH-stream token), granting the whole session. A token that rode on a request is scoped +/// to it: its grant covers only that request and never joins the session union. +/// /// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. pub struct Request { token: Bytes, issue: Option>, + scope: Option, } impl Request { @@ -861,11 +1041,31 @@ impl Request { Self { token, issue: Some(issue), + scope: None, + } + } + + /// A token that rode on one request, carrying the credential value, its structure + /// type, and the request it belongs to. + pub(crate) fn new_request( + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + issue: kio::Shared, + ) -> Self { + Self { + token: Bytes::from(token.value), + issue: Some(issue), + scope: Some(Scope { + path, + kind, + token_kind: token.kind, + }), } } /// The token the peer presented. Empty means the credential its connection - /// already carried, or none. + /// already carried, or none. For a request token this is the structure's value. pub fn token(&self) -> &Bytes { &self.token } @@ -879,6 +1079,11 @@ impl Request { closed(self.issue.as_ref().expect("unanswered request")).await } + /// The request this token rode on, or `None` for the connection's own credential. + pub fn scope(&self) -> Option<&Scope> { + self.scope.as_ref() + } + /// Grant the token. The grant holds until the returned [`Issued`] is revoked /// or dropped, or the peer withdraws the token. /// @@ -914,6 +1119,176 @@ fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { } } +/// 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>, +} + +impl RequestVerdict { + /// Wait for the acceptor's first answer: the grant it issued, or a refusal as the + /// [`Error`] whose request code the caller sends the peer. No consumer is + /// [`Error::Unsupported`]; an unanswered (dropped) request is + /// [`SessionError::Unauthorized`]. + pub(crate) async fn grant(&self) -> Result { + kio::wait(|waiter| self.poll_grant(waiter)).await + } + + /// Poll for the acceptor's first answer, so the caller can race the verify against the + /// live grant's deadline and serving rather than blocking on a bare await. + pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { + let Some(issue) = &self.issue else { + return Poll::Ready(Err(Error::Unsupported)); + }; + 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::Grant(grant)) => Ok(grant), + Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), + // Done with nothing written: the acceptor dropped the request unanswered. + None => Err(Error::Session(SessionError::Unauthorized)), + }) + } + + /// 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 + /// issued grant being dropped. `Pending` while the grant still stands. + pub(crate) 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 issued 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 { + // Tell the acceptor's Issued the request is over, so it stops revalidating this + // request's grant. An unread grant left in the outbox does not matter: the + // request is ending regardless. + issue.lock().peer.get_or_insert(Error::Cancel); + } + } +} + +/// 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. +/// +/// Ending a request grant never touches the session: only that one request ends. +pub(crate) struct RequestGrant { + 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, +} + +impl RequestGrant { + /// Hold `grant` (the acceptor's first answer, already awaited) for the request's life, + /// armed to lapse at its expiry. + pub(crate) fn new( + clock: &crate::time::Clock, + verdict: RequestVerdict, + grant: Grant, + path: crate::PathOwned, + kind: RequestKind, + ) -> Self { + let mut deadline = crate::time::Deadline::new(clock); + deadline.set(grant.expires); + Self { + verdict, + grant, + deadline, + path, + kind, + } + } + + /// Whether `grant` covers the request this guard holds. + fn covers(&self, grant: &Grant) -> bool { + self.kind.covers(grant, self.path.as_str()) + } + + /// The grant in force right now. Observed by the lifecycle tests. + #[cfg(test)] + pub(crate) fn grant(&self) -> &Grant { + &self.grant + } + + /// Apply a renewal's verdict, returning whether it renewed the request. + /// + /// A 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, changes nothing here; + /// the caller ends only that request. + pub(crate) fn renew(&mut self, verdict: RequestVerdict, res: Result) -> bool { + let grant = match res { + Ok(grant) if self.covers(&grant) => grant, + _ => return false, + }; + self.verdict = verdict; + self.deadline.set(grant.expires); + self.grant = grant; + true + } + + /// Resolve with the error that ends the request: the deadline lapsing + /// ([`Error::Unauthorized`]; [`Error::Expired`] once the code split lands), or the + /// acceptor revoking or dropping the grant (its code), or an acceptor-side update (a + /// replacement grant on the same token) 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(&mut self, waiter: &kio::Waiter) -> Poll { + loop { + if self.deadline.poll(waiter).is_ready() { + return Poll::Ready(Error::Unauthorized); + } + match self.verdict.poll_reply(waiter) { + Poll::Ready(Reply::Grant(grant)) => { + if !self.covers(&grant) { + return Poll::Ready(Error::Unauthorized); + } + self.deadline.set(grant.expires); + self.grant = grant; + // Re-poll: the new deadline may already have lapsed, or another reply may + // be waiting. + continue; + } + Poll::Ready(Reply::Refuse { code, .. }) => return Poll::Ready(Error::Session(code)), + Poll::Pending => return Poll::Pending, + } + } + } +} + /// A grant issued to one of the peer's tokens. Dropping it ends the grant. pub struct Issued { issue: kio::Shared, @@ -980,6 +1355,8 @@ pub(crate) struct Gate { path: crate::PathOwned, direction: Direction, epoch: u64, + /// Watch the limit alone, for a request a token authorized in place of the union. + limit_only: bool, } impl Gate { @@ -989,6 +1366,16 @@ impl Gate { path, direction, epoch: 0, + limit_only: false, + } + } + + /// A gate on the limit alone: a request token stands in for the union, never for the + /// ceiling this side set on the peer, so a later narrowing still ends the request. + pub(crate) fn limit(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + limit_only: true, + ..Self::new(handle, path, direction) } } @@ -998,7 +1385,11 @@ impl Gate { 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()) { + let allowed = match self.limit_only { + true => permit.within_limit(self.path.as_str()), + false => permit.matches(self.path.as_str()), + }; + if !allowed { return Poll::Ready(()); } } @@ -1232,3 +1623,296 @@ mod tests { assert!(default.poll(&waiter).is_pending()); } } + +#[cfg(test)] +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 tagged with the request it rode on, and + /// the grant the app answers is what the verdict resolves to. + #[moq_net_sim::test] + async fn a_request_token_reaches_the_acceptor_with_its_context() { + let handle = Handle::new(true); + let mut requests = handle.requests().expect("take the requests"); + let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe); + + let request = requests.next().await.expect("a request"); + assert_eq!(request.token(), &Bytes::from_static(b"jwt")); + assert_eq!( + request.scope(), + Some(&Scope { + path: subscribe_path(), + kind: RequestKind::Subscribe, + token_kind: 7, + }) + ); + let _issued = request.accept(Grant::all()); + + let grant = verdict.grant().await.expect("granted"); + assert!(grant.publish.matches("room/alice")); + } + + /// A request-borne token is verified even without the MoQ Auth extension: it rides the + /// request message, not the AUTH stream, so `requests()` hands a live queue on a session + /// whose `supported` is false and the acceptor admits it. This is the shape a standard + /// moq-transport peer (an encoder or CDN) presents when it does not negotiate the moq-dev + /// 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"); + for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind); + let request = requests.next().await.expect("a request reaches the acceptor"); + assert_eq!(request.scope().map(|scope| scope.kind), Some(kind)); + let _issued = request.accept(Grant::all()); + assert!( + verdict.grant().await.expect("granted").publish.matches("room/alice"), + "{kind:?} 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), subscribe_path(), RequestKind::Subscribe); + let err = verdict.grant().await.expect_err("no verifier"); + assert!(matches!(err, 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), subscribe_path(), RequestKind::Subscribe); + requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); + let err = verdict.grant().await.expect_err("refused"); + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + } + + /// 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), subscribe_path(), RequestKind::Subscribe); + 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), subscribe_path(), RequestKind::Subscribe); + 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"); + } + + /// A session token (the connection credential) carries no request context, so the + /// acceptor can tell it apart from a request token. + #[test] + fn a_session_token_has_no_request_context() { + let request = Request::new(Bytes::new(), kio::Shared::::default()); + assert_eq!(request.scope(), None); + } + + 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), subscribe_path(), RequestKind::Subscribe); + 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, answered, subscribe_path(), RequestKind::Subscribe); + assert_eq!(request_grant.grant().expires, expires); + assert!(expires.is_some()); + } + + /// 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), subscribe_path(), RequestKind::Subscribe); + 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 mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. + let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe); + 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), "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), subscribe_path(), RequestKind::Subscribe); + // 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 mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + 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), subscribe_path(), RequestKind::Subscribe); + 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 mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // The renewal token is refused, so it renews nothing and the old grant is untouched. + let renewal = handle.verify_request(token(b"bad", 0), subscribe_path(), RequestKind::Subscribe); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "bad token"); + let refused = renewal.grant().await; + assert!(matches!(refused, Err(Error::Session(SessionError::Unauthorized)))); + assert!(!request_grant.renew(renewal, refused), "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; + 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), subscribe_path(), RequestKind::Subscribe); + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // 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"); + 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 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), subscribe_path(), RequestKind::Subscribe); + // 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 mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + issued.revoke(SessionError::Unauthorized, "revoked"); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } +} diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index 56f590d0f1..4009b0095b 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 46e31e30df..771148859a 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -570,6 +570,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 db1eb75de2..134398a053 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -853,7 +853,18 @@ impl ControlStreamAdapter { let raw = encode_raw(type_id, &body, self.version); // Classify and route - match classify(type_id, &body, self.version, &self.shared.namespaces)? { + let classified = classify(type_id, &body, self.version, &self.shared.namespaces)?; + // An answer to one of our updates names the update, which has no stream of its own, + // so it goes to the request the update targets. Only REQUEST_OK and REQUEST_ERROR + // answer an update. + if matches!(type_id, ietf::RequestOk::ID | ietf::RequestError::ID) + && let Route::Response(id) | Route::CloseStream(id) = classified + && let Some(target) = self.control.follow_up_target(id) + { + self.shared.push(target, raw); + continue; + } + match classified { Route::NewRequest(request_id) => { let permit = self.control.accept(request_id)?; self.shared.open_incoming(request_id, raw, permit)? @@ -1344,8 +1355,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); @@ -1516,6 +1529,7 @@ mod tests { request_id, track_namespace: crate::Path::new(namespace), cluster: None, + authorization_token: None, } } @@ -1877,8 +1891,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/control.rs b/rs/moq-net/src/ietf/control.rs index c4a9bf0fed..44bbf97394 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -81,6 +81,8 @@ struct ControlState { modern: Spent, /// The most runs `modern` may hold. Forgetting one would make its IDs usable again. history: u64, + /// Our outstanding updates, by their own Request ID, to the request each updates. + follow_ups: std::collections::HashMap, } /// The MAX_REQUEST_ID window we grant the peer. @@ -116,6 +118,7 @@ impl Control { // The peer takes the other parity: a client's IDs are even, a server's odd. modern: Spent::new(u64::from(client)), history: crate::session::Limits::default().requests(), + follow_ups: Default::default(), }), } } @@ -253,6 +256,38 @@ impl Control { } } +impl Control { + /// Route the answer to our update `update` back to the request `target` it updates, until + /// the returned guard drops. + /// + /// Drafts 15 and 16 name an update's own Request ID in its answer, and an update has no + /// stream of its own on the control stream. + pub fn follow_up(&self, update: RequestId, target: RequestId) -> FollowUp { + self.state.lock().follow_ups.insert(update, target); + FollowUp { + control: self.clone(), + update, + } + } + + /// The request an answer naming `id` belongs to, when `id` is an update of ours. + pub fn follow_up_target(&self, id: RequestId) -> Option { + self.state.lock().follow_ups.remove(&id) + } +} + +/// An update of ours awaiting its answer, routed back to the request it updates until dropped. +pub(super) struct FollowUp { + control: Control, + update: RequestId, +} + +impl Drop for FollowUp { + fn drop(&mut self) { + self.control.state.lock().follow_ups.remove(&self.update); + } +} + /// One request the peer holds open, retired (and eventually granted back) on drop. pub(super) struct RequestPermit(Control); @@ -266,6 +301,21 @@ impl Drop for RequestPermit { mod tests { use super::*; + /// An answer to our update maps to the request it updates once, and not after the + /// update stops waiting on it. + #[test] + fn an_update_answer_follows_its_request() { + let control = Control::new(None, true); + let answered = control.follow_up(RequestId(8), RequestId(2)); + assert_eq!(control.follow_up_target(RequestId(8)), Some(RequestId(2))); + assert_eq!(control.follow_up_target(RequestId(8)), None, "routed once"); + drop(answered); + + let abandoned = control.follow_up(RequestId(10), RequestId(2)); + drop(abandoned); + assert_eq!(control.follow_up_target(RequestId(10)), None, "dropped with its update"); + } + fn granted(control: &Control) -> Option { match control.poll_grant(&kio::Waiter::noop()) { Poll::Ready(max) => Some(max.0), diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 50f2fcdce8..8ecccc1431 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -29,6 +29,7 @@ mod publish_namespace; mod publisher; mod request; mod request_stream; +mod request_update; mod session; pub mod solicit; mod subscribe; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 6a64130f68..c855824dd4 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. @@ -1276,7 +1278,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_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index a54e146125..050d9564f7 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. The parameter may repeat; the + /// first instance is the request's credential, and every instance has been decoded. + pub authorization_token: Option, } impl PublishNamespace<'_> { @@ -37,7 +42,6 @@ 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, @@ -60,6 +64,7 @@ impl PublishNamespace<'_> { request_id, track_namespace, cluster, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -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 { @@ -95,6 +111,9 @@ pub struct PublishNamespaceUpdate { pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, + /// The `AUTHORIZATION TOKEN` (0x03) renewing the advertisement's credential, if any. The + /// parameter may repeat; the first instance is kept, and every instance has been decoded. + pub authorization_token: Option, } impl PublishNamespaceUpdate { @@ -104,6 +123,7 @@ impl PublishNamespaceUpdate { request_id, hops: (held.hops != next.hops).then(|| next.hops.clone()), cost: (held.cost != next.cost).then_some(next.cost), + authorization_token: None, } } @@ -129,6 +149,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, ); @@ -145,14 +166,18 @@ impl Message for PublishNamespaceUpdate { } _ => RequestId::decode(r, version)?, }; - // 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 }) + Ok(Self { + request_id, + hops, + cost, + authorization_token: authorization_token.into_iter().next(), + }) } } @@ -369,6 +394,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 +484,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 +500,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); @@ -574,6 +602,7 @@ mod tests { request_id: RequestId(2), hops: None, cost: Some(0), + authorization_token: None, }; let mut buf = Vec::new(); assert!( diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 76b2da615c..7a5046d722 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -21,7 +21,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; @@ -334,6 +334,31 @@ 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. + UpdateFailed(Error), +} + +impl Served { + /// The PUBLISH_DONE status and reason this end reports. + fn status(&self) -> (ietf::PublishDoneStatus, &'static str) { + match self { + 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. @@ -345,6 +370,8 @@ enum NamespaceEvent { Retry, /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. Regrant(crate::auth::Permit), + /// The client replaced its request token: re-present it on every live announce. + TokenRefresh(Option), } #[derive(Clone)] @@ -379,6 +406,10 @@ pub(super) struct Publisher { pub(super) subscriptions: crate::session::Slots, // Ranks every subscription's group streams against each other for the transport. priority: PriorityQueue, + // The AUTHORIZATION TOKEN this side presents on its PUBLISH_NAMESPACE requests and their + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } struct Serve(Arc); @@ -448,6 +479,7 @@ where owed: Default::default(), subscriptions: Default::default(), priority: Default::default(), + request_token: crate::RequestToken::default(), } } @@ -457,6 +489,15 @@ where self } + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the + /// PUBLISH_NAMESPACE requests this side sends, and on their REQUEST_UPDATEs, so a client + /// authorizes its announces the standard draft-17+ way (MoQ request-token). A shared handle, + /// so a replaced token is re-presented on each live announce (draft-17+). + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { + self.request_token = token; + self + } + /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -707,19 +748,66 @@ 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 + // origin. The session grant is checked first; a request it does not cover falls + // back to an AUTHORIZATION TOKEN carried on the SUBSCRIBE itself (MoQ + // request-token), verified by the app's acceptor and scoped to this one request. + // Every admitted subscription is gated so it ends if the session narrows: a + // union-authorized one on the union and the limit, a token-authorized one on the + // limit alone (the union never covered it; its grant ends with the request). + let mut request_grant = None; + // A request presenting a token is authorized by it whenever the session union does + // not positively cover the path. `covers` treats a `None` union (no answer yet, or a + // session without the AUTH extension) as NOT covering, so a standard peer's token is + // verified rather than admitted by the permissive default. A token-less request keeps + // the permissive `allows` default unchanged. + let mut gate = if let Some(token) = &msg.authorization_token + && !self + .auth + .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + // The token stands in for the union, never for the limit this side set on the + // peer: a request outside that ceiling is refused whatever the token grants. + if !self + .auth + .within_limit(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + } + // Held for the subscription's life: the request ends when this grant lapses, is + // revoked, or stops covering it, or when the limit narrows past it (the gate), + // never the session. It authorizes nothing else and never joins the session union. + let admitted = self + .auth + .admit_request( + &self.runtime, + token.0.clone(), + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ) + .await; + match admitted { + Ok(grant) => request_grant = Some(grant), + Err(err) => return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await, + } + crate::auth::Gate::limit( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ) + } else if self .auth .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) { + crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ) + } else { let err = Error::Unauthorized; return self.reject_subscribe(stream, request_id, &err, "not granted").await; - } + }; // Legal requests we can't honor are refused one at a time, never by closing the // session. A subscription that forwards nothing is only useful to a subscriber // that later turns forwarding on, and serving a Range Filter unfiltered would @@ -884,7 +972,14 @@ where }; // 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 serve = self.run_subscription( + &mut stream, + &mut track_serve, + &mut request_finished, + fill, + request_grant, + msg.track_namespace.to_owned(), + ); let mut serve = std::pin::pin!(serve); kio::wait(|waiter| { if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { @@ -892,7 +987,7 @@ where } 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(Some(Served::Track(Err(Error::Unauthorized)))); } Poll::Pending }) @@ -900,7 +995,12 @@ where }; let completed = served.is_some(); - let res = served.unwrap_or(Ok(())); + let served = served.unwrap_or(Served::Track(Ok(()))); + let (status, reason) = served.status(); + let res = match served { + Served::Track(res) => res, + Served::UpdateFailed(err) => Err(err), + }; // 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 @@ -938,12 +1038,6 @@ where let streams = track_serve.opened(); // 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 @@ -971,32 +1065,82 @@ where serve: &mut TrackServe, finished: &mut bool, fill: impl std::future::Future, - ) -> Option> { + mut request_grant: Option, + namespace: crate::PathOwned, + ) -> 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 and the old grant's deadline (never a bare await), so media keeps + // flowing and the old deadline still fires when the acceptor is slow. 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, + // The request grant ended the subscription early. + Ended(Error), + // 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, &namespace, &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 Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %namespace, %err, "request token grant ended"); + return Poll::Ready(Turn::Ended(err)); + } + 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 { @@ -1009,15 +1153,15 @@ where session .clone() .close(crate::SessionError::from(&err).to_code(), "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 => {} } } @@ -1031,70 +1175,153 @@ 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(crate::SessionError::from(&err).to_code(), &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))); + Turn::Served(res) => return Some(Served::Track(res)), + Turn::Cancelled => return None, + Turn::Ended(err) => return Some(Served::Track(Err(err))), + Turn::Renewal(request_update::Verdict { + verdict, + grant: res, + request_id: rid, + }) => { + // Only a token-authorized request starts a renewal. + let rg = request_grant.as_mut().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. + if rg.renew(verdict, res) { + if answer && let Err(err) = self.write_request_ok(&mut stream.writer, rid).await { + return Some(Served::Track(Err(err))); + } + continue; } - if let Some(priority) = update.priority - && let Err(err) = serve.set_priority(super::priority::from_wire(priority)) + // 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, &Error::Unauthorized, "renewal not granted") + .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(Error::Unauthorized)); + } + // 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, &namespace, &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, + namespace: &crate::PathOwned, + 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)); + if let Some(err) = update.refused { + let reason = match err { + Error::Unsupported => "REQUEST_UPDATE parameters not supported", + _ => "repeated AUTHORIZATION TOKEN", + }; + 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), true) = (update.authorization_token, request_grant.is_some()) { + let verdict = self + .auth + .verify_request(token.0, namespace.clone(), crate::auth::RequestKind::Subscribe); + renewal.verify(verdict, 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.varint(ietf::RequestOk::ID).await?; + 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: @@ -2135,7 +2362,16 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - let permitted = ns.permitted(path); + // A request token authorizes an announce the connection grant does not cover, but only + // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE + // entry has no slot for one. So the grant still filters inline entries, and stands down + // only for requests, where the server's covers-gate refuses a bad token per request. + // The token never stands in for the limit this side set on the peer. + let carries_token = matches!(ns.target, Target::Requests(_)) && self.request_token.peek().is_some(); + let permitted = match carries_token { + true => ns.permit.within_limit(path.as_str()), + false => ns.permitted(path), + }; let Namespaces { peer, target, @@ -2277,6 +2513,7 @@ where request_id, track_namespace: path.as_path(), cluster, + authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), }) .await?; @@ -2514,6 +2751,86 @@ where Ok(()) } + /// Re-present the client's request token on every live announce as a token-only + /// REQUEST_UPDATE (MoQ request-token renewal), so a refreshed credential reaches the peer + /// before the old grant lapses. + /// + /// Draft-17+ only: earlier drafts have no PUBLISH_NAMESPACE_UPDATE, so a token set on them + /// rides the initial advertisement and is not renewed in place. Clearing the token (`None`) + /// is not a renewal and sends nothing. A refusal withdraws the announce, since the receiver + /// closes its stream on a renewal it cannot grant; the retry re-offers it carrying the new + /// token, honoring any retry interval the refusal named. + async fn refresh_request_token( + &self, + ns: &mut Namespaces, + token: Option, + ) -> Result<(), Error> { + let Some(token) = token else { + return Ok(()); + }; + if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + return Ok(()); + } + + let suffixes: Vec = ns.requests.keys().cloned().collect(); + for suffix in suffixes { + let request_id = self.control.next_request_id(&self.runtime).await?; + let Some(request) = ns.requests.get_mut(&suffix) else { + continue; + }; + let update = ietf::PublishNamespaceUpdate { + request_id, + hops: None, + cost: None, + authorization_token: Some(crate::ietf::token::RequestToken(token.clone())), + }; + request.stream.writer.encode_message(&update).await?; + + let absolute = self.origin.absolute(&request.path).to_owned(); + let Some((type_id, body)) = self.read_response(&mut request.stream).await? else { + tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); + // The peer never answered: the stream is gone, so drop the advertisement. + ns.requests.remove(&suffix); + // Forget what was sent so reconcile re-offers it with the new token, and re-arm + // the deferred-only retry sweep if an earlier refusal still holds it back. + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.sent = Advert::None; + watch.deferred = watch.refused.pending(); + } + continue; + }; + let mut data = body.decoder(self.version); + match type_id { + ietf::RequestOk::ID => { + let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; + tracing::debug!(broadcast = %absolute, "request token refreshed"); + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + // A refused renewal means the receiver closed its stream, so the announce + // cannot stand on the old grant: withdraw it and honor the refusal's retry + // interval, exactly as a repriced update does. The retry re-offers it fresh, + // carrying the new token. + tracing::warn!(broadcast = %absolute, message = ?msg, "request token refresh refused"); + let refused = self.refusal(msg.retry_interval); + let deferred = refused.pending(); + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.refused = refused; + watch.sent = Advert::None; + // Re-arm the retry sweep (which only revisits deferred namespaces) so a + // bounded refusal re-offers this fresh with the new token once its interval + // passes; a permanent refusal (Never) is not pending and stays withdrawn. + watch.deferred = deferred; + } + } + _ => return Err(Error::UnexpectedMessage), + } + } + Ok(()) + } + /// Close out every open PUBLISH_NAMESPACE request. A no-op for a loop whose entries /// ride the SUBSCRIBE_NAMESPACE stream itself, which retracts them by ending. async fn withdraw_requests( @@ -2754,6 +3071,10 @@ where let mut retry_at: Option = None; let mut retry_delay = RETRY_BASE; + // The request token last presented on these announces (their initial value). A client + // replacing it wakes the loop, which re-presents the new one on each live announce. + let mut last_token = self.request_token.peek(); + // Stream updates (origin route (un)announces), bailing if the peer closes // its side first. let res = loop { @@ -2792,6 +3113,11 @@ where crate::announce::Event::End(update) => (update, false, false), }))); } + // A replaced request token is re-presented on each live announce, below the + // origin updates so a busy loop still makes progress on both. + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(NamespaceEvent::TokenRefresh(token)); + } if retry.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Retry); } @@ -2802,6 +3128,10 @@ where match event { NamespaceEvent::Closed(res) => break res, + NamespaceEvent::TokenRefresh(token) => { + last_token = token.clone(); + self.refresh_request_token(&mut ns, token).await?; + } NamespaceEvent::Retry => { retry_at = None; retry_delay = (retry_delay * 2).min(RETRY_MAX); @@ -4473,11 +4803,14 @@ 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, + crate::Path::new("room").to_owned(), + )); assert_eq!( futures::poll!(run.as_mut()).is_ready(), super::super::request_stream::fin_cancels(version), @@ -4521,7 +4854,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, crate::Path::new("room").to_owned()); assert!(futures::poll!(std::pin::pin!(run)).is_ready(), "{version}"); } } @@ -4604,10 +4937,14 @@ 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, + crate::Path::new("room").to_owned(), + )); 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); @@ -4637,7 +4974,7 @@ mod serve_tests { let mut finished = false; 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, crate::Path::new("room").to_owned()) ); // An unfinished group keeps its stream open. @@ -4675,9 +5012,19 @@ 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, + crate::Path::new("room").to_owned(), + ) .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, @@ -4878,12 +5225,51 @@ 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, + crate::Path::new("room").to_owned(), + ) + .await + .expect("the track's error ends the subscription"); + assert!(matches!(served, Served::Track(Err(Error::Unsupported))), "{served:?}"); + assert_eq!(served.status().0, ietf::PublishDoneStatus::InternalError); + assert_eq!( + Served::UpdateFailed(Error::Unsupported).status().0, + ietf::PublishDoneStatus::UpdateFailed + ); + } + /// 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(); @@ -4910,8 +5296,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") @@ -4953,8 +5339,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 { @@ -4965,8 +5351,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(); @@ -4999,11 +5387,14 @@ 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, + crate::Path::new("room").to_owned(), + )); assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); assert_eq!(h.track.subscription().unwrap().priority, 245, "{version}"); @@ -5044,9 +5435,19 @@ 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, + crate::Path::new("room").to_owned(), + ) .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 { @@ -5071,8 +5472,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(); @@ -5128,9 +5531,9 @@ 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, crate::Path::new("room").to_owned()) .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}"); } @@ -5197,9 +5600,9 @@ 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, crate::Path::new("room").to_owned()) .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{version}: {served:?}"); + assert!(matches!(served, 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(); @@ -5246,6 +5649,7 @@ mod serve_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } } @@ -7689,80 +8093,1582 @@ 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"); } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::coding::Decode; - use crate::lite::test_transport::SinkSession; - use crate::model::ProduceTest; - use futures::FutureExt; - async fn settle() { - moq_net_sim::sleep(Duration::from_millis(1)).await; + /// 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])) } - fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { - let writes = log.writes.lock().unwrap(); - writes.windows(needle.len()).filter(|window| *window == needle).count() - } + /// [`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(); - /// NAMESPACE and NAMESPACE_DONE suffixes on a SUBSCRIBE_NAMESPACE stream, in order. - fn namespace_events(bytes: &[u8], version: Version) -> Vec<(u64, String)> { - let mut dec = crate::coding::Decoder::new(bytes, version.into()); - let mut out = Vec::new(); - while !dec.is_empty() { - let id = dec.varint().expect("message type"); - match id { - ietf::RequestOk::ID => { - ietf::RequestOk::decode(&mut dec, version).expect("request ok"); - } - ietf::Namespace::ID => { - let msg = ietf::Namespace::decode(&mut dec, version).expect("namespace"); - out.push((id, msg.suffix.as_str().to_owned())); - } - ietf::NamespaceDone::ID => { - let msg = ietf::NamespaceDone::decode(&mut dec, version).expect("namespace done"); - out.push((id, msg.suffix.as_str().to_owned())); - } - other => panic!("unexpected message {other:#x}"), - } + 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, } - out } - /// A SETUP slot already filled with what the peer declared. The announce loops block - /// on it, so a test that leaves it empty is a test that never advertises. - fn declared(solicit: Option) -> peer::PeerSetup { - let slot = peer::PeerSetup::default(); - slot.set(peer::Peer { - solicit, - ..Default::default() - }); - slot + /// 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()), + } } - /// moq-transport cannot carry the receiver's max delay budget, so the serving - /// subscription must preserve everything the producer still retains. - #[test] - fn serving_subscription_keeps_retained_backlog() { - let producer = track::Producer::new( - std::sync::Arc::new(crate::broadcast::Info::default()), - "video", - crate::track::Info::timed(), + /// 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(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, ); - for millis in [0, 1000] { - let mut group = producer.append_group().unwrap(); - group - .write_frame(crate::Timestamp::from_millis(millis).unwrap(), b"frame".as_slice()) - .unwrap(); - group.finish().unwrap(); - } + assert!( + !auth.allows(crate::auth::Direction::Publish, "room"), + "the session grant must not cover the request path" + ); + (auth, requests, cred) + } - let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); - let mut subscriber = producer.subscribe(subscription); + /// A token-bearing client does not self-censor on its connection grant: the + /// publisher advertises a namespace its connection grant does not cover, carrying the token, + /// and the server's covers-gate is left to be the authority. A token-less client with the same + /// grant filters that announce, as before. + #[moq_net_sim::test] + async fn a_token_bearing_client_announces_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "cam". The presented credential and + // its grant are held for the run's lifetime, else the union reverts to permissive. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + 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 { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + (auth, cred) + } + + // With a token, "cam" is advertised despite the grant not covering it (the token rides it). + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let (auth, _cred) = seed_auth(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut advertised = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + advertised = true; + break; + } + settle().await; + } + assert!( + advertised, + "a token-bearing client must announce outside its connection grant" + ); + + // Without a token, the same grant filters "cam": nothing is advertised. + let origin2 = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam2 = origin2.announce("cam", crate::origin::Route::default()).unwrap(); + let session2 = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log2 = session2.log.clone(); + let peer_setup2 = peer::PeerSetup::default(); + peer_setup2.set(peer::Peer::default()); + let (auth2, _cred2) = seed_auth(); + let publisher2 = Publisher::new( + crate::time::Clock::sim(), + session2, + origin2.consume(), + Control::new(None, false), + None, + peer_setup2, + VERSION, + ) + .with_auth(auth2); + let mut run2 = std::pin::pin!(publisher2.run_publish_namespaces()); + for _ in 0..80 { + assert!(futures::poll!(run2.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log2, b"cam"), + 0, + "a token-less client self-censors on its grant" + ); + } + + /// A request token stands in for the connection grant, never for the limit this side set + /// on the peer: a token-bearing client still withholds an announce outside that limit. + #[moq_net_sim::test] + async fn a_request_token_does_not_lift_the_local_limit_on_announces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the limit still withholds the announce"); + } + + /// A peer that requires solicitation gets inline NAMESPACE entries, which have no slot for a + /// request token, so the token cannot authorize them: the connection grant still filters the + /// answer to its SUBSCRIBE_NAMESPACE. + #[moq_net_sim::test] + async fn a_request_token_does_not_lift_the_grant_on_inline_namespaces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let _other = origin.announce("other/mic", crate::origin::Route::default()).unwrap(); + settle().await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + 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 { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + let mut covered = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"mic") >= 1 { + covered = true; + break; + } + settle().await; + } + assert!(covered, "a namespace the grant covers is advertised inline"); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the grant still filters inline entries"); + assert_eq!(occurrences(&log, &token), 0, "an inline entry carries no token"); + } + + /// 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: 300, + 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), 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::Unauthorized))), + "{version:?}: 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), + "{version:?}: UPDATE_FAILED" + ); + 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 UPDATE_FAILED, 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), "renewal not granted").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), "renewal not granted").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::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), "renewal not granted").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, so `allows` is permissive; a token-bearing SUBSCRIBE must still be + /// verified via `covers`, not admitted by that default. The acceptor is consulted; with the + /// bug the token path was bypassed and the token silently ignored. + #[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(); + assert!( + auth.allows(crate::auth::Direction::Publish, "room"), + "None union is permissive" + ); + assert!( + !auth.covers(crate::auth::Direction::Publish, "room"), + "None union does not cover" + ); + + 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 (_auth, _h, mut serving) = serve_token_subscribe(read_only, None).await; + assert!(still_serving(&mut serving).await, "a read grant admits it"); + let (_auth, _h, mut serving) = serve_token_subscribe(write_only, None).await; + assert!(!still_serving(&mut serving).await, "a write grant refuses it"); + } + + /// A request token stands in for the union, never for the limit this side set on the + /// peer: a SUBSCRIBE outside that ceiling is refused whatever the token grants, and a + /// later narrowing ends one the token admitted. + #[moq_net_sim::test] + async fn a_request_token_cannot_exceed_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 refuses an all-covering token" + ); + + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), None).await; + assert!( + still_serving(&mut serving).await, + "an unlimited session admits the token" + ); + auth.authorize(&crate::auth::Grant::default()); + assert!( + !still_serving(&mut serving).await, + "narrowing the limit ends a token-authorized subscription" + ); + } + + /// 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 + } + } + + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::coding::Decode; + use crate::lite::test_transport::SinkSession; + use crate::model::ProduceTest; + use futures::FutureExt; + + async fn settle() { + moq_net_sim::sleep(Duration::from_millis(1)).await; + } + + fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { + let writes = log.writes.lock().unwrap(); + writes.windows(needle.len()).filter(|window| *window == needle).count() + } + + /// NAMESPACE and NAMESPACE_DONE suffixes on a SUBSCRIBE_NAMESPACE stream, in order. + fn namespace_events(bytes: &[u8], version: Version) -> Vec<(u64, String)> { + let mut dec = crate::coding::Decoder::new(bytes, version.into()); + let mut out = Vec::new(); + while !dec.is_empty() { + let id = dec.varint().expect("message type"); + match id { + ietf::RequestOk::ID => { + ietf::RequestOk::decode(&mut dec, version).expect("request ok"); + } + ietf::Namespace::ID => { + let msg = ietf::Namespace::decode(&mut dec, version).expect("namespace"); + out.push((id, msg.suffix.as_str().to_owned())); + } + ietf::NamespaceDone::ID => { + let msg = ietf::NamespaceDone::decode(&mut dec, version).expect("namespace done"); + out.push((id, msg.suffix.as_str().to_owned())); + } + other => panic!("unexpected message {other:#x}"), + } + } + out + } + + /// A SETUP slot already filled with what the peer declared. The announce loops block + /// on it, so a test that leaves it empty is a test that never advertises. + fn declared(solicit: Option) -> peer::PeerSetup { + let slot = peer::PeerSetup::default(); + slot.set(peer::Peer { + solicit, + ..Default::default() + }); + slot + } + + /// moq-transport cannot carry the receiver's max delay budget, so the serving + /// subscription must preserve everything the producer still retains. + #[test] + fn serving_subscription_keeps_retained_backlog() { + let producer = track::Producer::new( + std::sync::Arc::new(crate::broadcast::Info::default()), + "video", + crate::track::Info::timed(), + ); + for millis in [0, 1000] { + let mut group = producer.append_group().unwrap(); + group + .write_frame(crate::Timestamp::from_millis(millis).unwrap(), b"frame".as_slice()) + .unwrap(); + group.finish().unwrap(); + } + + let subscription = serving_subscription(128); + assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); + let mut subscriber = producer.subscribe(subscription); for sequence in [0, 1] { let group = subscriber .recv_group() @@ -8702,24 +10608,248 @@ mod tests { } } - /// A peer out of stream credit parks the open. That must not wedge the loop, because - /// the withdrawals queued behind it are the only thing that frees a slot: an open that - /// never gives up is a deadlock, not a delay. - /// - /// Draft-14 so the withdrawal names its namespace on the wire, which is what makes the - /// loop's progress visible while every open is blocked. + /// A peer out of stream credit parks the open. That must not wedge the loop, because + /// the withdrawals queued behind it are the only thing that frees a slot: an open that + /// never gives up is a deadlock, not a delay. + /// + /// Draft-14 so the withdrawal names its namespace on the wire, which is what makes the + /// loop's progress visible while every open is blocked. + #[moq_net_sim::test] + async fn a_parked_open_still_lets_a_namespace_be_withdrawn() { + const VERSION: Version = Version::Draft14; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Open: the peer still has credit for the first advertisement, and answers it. + let gate = kio::Producer::new(true); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], gate.consume()); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") > 0 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, b"first-cam"), + 1, + "the first advertisement never went out" + ); + + // Credit runs out, and a second namespace wants a stream we cannot get. + set_gate(&gate, false); + let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Retiring the first frees a slot and needs no new stream, so the loop has to reach + // it despite the open above. + drop(first); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") >= 2 { + break; + } + tick().await; + } + assert_eq!( + occurrences(&log, b"first-cam"), + 2, + "PUBLISH_NAMESPACE_DONE never sent: the open wedged the loop" + ); + + // Credit returns, and nothing else about the origin changes. + set_gate(&gate, true); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"second-cam") > 0 { + break; + } + tick().await; + } + assert_eq!( + occurrences(&log, b"second-cam"), + 1, + "never retried once credit returned" + ); + } + + /// A namespace nobody can advertise any more is not pending, whatever happened before. + /// `deferred` outliving the want would arm the retry timer forever for a wire message + /// that can never happen: not a spin, but a session that never sleeps. + #[moq_net_sim::test] + async fn a_namespace_that_stops_being_advertisable_stops_being_deferred() { + let assigned = crate::Hop::new(777).unwrap(); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + + // A route that already passed through us must never be forwarded: `select` + // wants nothing, which is what the peer already holds. + let mut hops = crate::Hops::new(); + hops.push(crate::Hop::new(1).unwrap()).unwrap(); + + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + Some(assigned), + declared(Some(false)), + Version::Draft17, + ); + + // The state a refused or failed offer leaves behind: the peer holds nothing, and + // the loop is coming back to it on a timer. + let suffix: crate::PathOwned = crate::Path::new("from/peer").to_owned(); + let mut watch = Watched::new(crate::origin::Route::default().with_hops(hops)); + watch.deferred = true; + + let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests(None)); + ns.watched.insert(suffix.clone(), watch); + + publisher.sync_namespace(&mut ns, &suffix, &suffix).await.unwrap(); + + assert!( + !ns.watched[&suffix].deferred, + "the retry timer stays armed for a namespace that can never be advertised" + ); + } + + /// A minimum wait binds every path back to the namespace, not just the retry sweep. + /// A route change re-prices the advertisement; it does not excuse us from the wait the + /// peer asked for. + #[moq_net_sim::test] + async fn a_route_change_still_waits_out_a_refusal() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + // One epoch, so the second route re-prices the instance rather than restart it. + let epoch = crate::Epoch::mint(); + let cam = origin + .announce("solo-cam", crate::origin::Route::default().with_epoch(epoch.clone())) + .unwrap(); + settle().await; + + // Refused with a wait far longer than any backoff the loop would take on its own. + let refusal = publish_namespace_error(VERSION, 600_000).await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"solo-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); + + // A cheaper second route makes the advertisement worth re-pricing, which is a + // path back into the reconciliation that does not go through the retry timer. + let _standby = origin + .announce( + "solo-cam", + crate::origin::Route::default().with_epoch(epoch).with_cost(0), + ) + .unwrap(); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + tick().await; + } + + assert_eq!( + occurrences(&log, b"solo-cam"), + 1, + "re-offered inside the wait the peer asked for" + ); + + drop(cam); + } + + /// A SETUP slot for a peer that negotiated the cluster extension, so an + /// advertisement carries a path and a cost worth repricing. + fn clustered(solicit: Option) -> peer::PeerSetup { + let slot = peer::PeerSetup::default(); + slot.set(peer::Peer { + cluster: cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: None, + }, + solicit, + hidden: false, + auth: false, + active_count: false, + }); + slot + } + + /// The bytes of one REQUEST_UPDATE, framed as the publisher writes it. + async fn request_update(version: Version, msg: &ietf::PublishNamespaceUpdate) -> 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::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A relay that starts carrying a namespace reprices it with REQUEST_UPDATE on the + /// request that already carries it, sending the changed parameter only. The new cost + /// is 0, which has to be explicit: REQUEST_UPDATE keeps an omitted parameter, so + /// leaving it out would keep the old price. #[moq_net_sim::test] - async fn a_parked_open_still_lets_a_namespace_be_withdrawn() { - const VERSION: Version = Version::Draft14; + async fn a_repricing_is_a_request_update() { + const VERSION: Version = Version::Draft19; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + // Forward the update at once: the hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + // One epoch, so the warm route re-prices the instance rather than restart it. + let epoch = crate::Epoch::mint(); + let _cold = origin + .announce( + "cam", + crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + ) + .unwrap(); settle().await; - // Open: the peer still has credit for the first advertisement, and answers it. - let gate = kio::Producer::new(true); + // One stream: the PUBLISH_NAMESPACE request and its update, each answered OK. let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], gate.consume()); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); let log = session.log.clone(); let publisher = Publisher::new( @@ -8728,124 +10858,224 @@ mod tests { origin.consume(), Control::new(None, false), None, - declared(Some(false)), + clustered(Some(false)), VERSION, ); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") > 0 { + if occurrences(&log, b"cam") >= 1 { break; } settle().await; } - assert_eq!( - occurrences(&log, b"first-cam"), - 1, - "the first advertisement never went out" - ); - - // Credit runs out, and a second namespace wants a stream we cannot get. - set_gate(&gate, false); - let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); - settle().await; + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - // Retiring the first frees a slot and needs no new stream, so the loop has to reach - // it despite the open above. - drop(first); + // We start carrying it: a free route outranks the cold one. + let _warm = origin + .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .unwrap(); + // The request consumed id 1, so the update takes the next of our parity. The + // path is unchanged and omitted; the cost is an explicit 0. + let expected = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: Some(0), + authorization_token: None, + }, + ) + .await; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") >= 2 { + if occurrences(&log, &expected) >= 1 { break; } - tick().await; + settle().await; } - assert_eq!( - occurrences(&log, b"first-cam"), - 2, - "PUBLISH_NAMESPACE_DONE never sent: the open wedged the loop" - ); - // Credit returns, and nothing else about the origin changes. - set_gate(&gate, true); + assert_eq!(occurrences(&log, &expected), 1, "REQUEST_UPDATE with an explicit 0"); + assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); + assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); + } + + /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK + /// to a REQUEST_UPDATE is the peer breaking the extension too. + #[moq_net_sim::test] + async fn a_counted_update_ok_is_a_violation() { + const VERSION: Version = Version::Draft19; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let epoch = crate::Epoch::mint(); + let _cold = origin + .announce( + "cam", + crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + ) + .unwrap(); + settle().await; + + // The PUBLISH_NAMESPACE is answered cleanly; its update's OK carries a count. + let counted = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(counted.clone()), VERSION); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: Some(0), + }) + .await + .unwrap(); + let counted = counted.writes.lock().unwrap().clone(); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, counted].concat()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"second-cam") > 0 { + if occurrences(&log, b"cam") >= 1 { break; } - tick().await; + settle().await; } - assert_eq!( - occurrences(&log, b"second-cam"), - 1, - "never retried once credit returned" - ); + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + + // Repricing sends the REQUEST_UPDATE whose OK is counted. + let _warm = origin + .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .unwrap(); + + let res = moq_net_sim::timeout(Duration::from_secs(5), run) + .await + .expect("the violation ends the loop"); + assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); + // Closed at the source, since the solicited path's request task only logs errors. + let code = crate::SessionError::ProtocolViolation.to_code(); + assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); } - /// A namespace nobody can advertise any more is not pending, whatever happened before. - /// `deferred` outliving the want would arm the retry timer forever for a wire message - /// that can never happen: not a spin, but a session that never sleeps. + /// A route from a different original publisher under the same epoch (a replica) + /// updates the advertisement in place, like any other change within an instance: + /// withdrawing it would make the namespace briefly vanish downstream just because + /// its replica moved. #[moq_net_sim::test] - async fn a_namespace_that_stops_being_advertisable_stops_being_deferred() { - let assigned = crate::Hop::new(777).unwrap(); + async fn a_replica_change_is_a_request_update() { + const VERSION: Version = Version::Draft19; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + // Forward the update at once: the hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + let publisher_a = crate::Hops::try_from(vec![crate::Hop::new(7).unwrap()]).unwrap(); + let publisher_b = crate::Hops::try_from(vec![crate::Hop::new(8).unwrap()]).unwrap(); + let epoch = crate::Epoch::mint(); + let _from_a = origin + .announce( + "cam", + crate::origin::Route::default() + .with_epoch(epoch.clone()) + .with_hops(publisher_a) + .with_cost(4), + ) + .unwrap(); + settle().await; - // A route that already passed through us must never be forwarded: `select` - // wants nothing, which is what the peer already holds. - let mut hops = crate::Hops::new(); - hops.push(crate::Hop::new(1).unwrap()).unwrap(); + // One stream: the advertisement from A and its update to B, each answered OK. + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let log = session.log.clone(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), - Some(assigned), - declared(Some(false)), - Version::Draft17, + None, + clustered(Some(false)), + VERSION, ); - // The state a refused or failed offer leaves behind: the peer holds nothing, and - // the loop is coming back to it on a timer. - let suffix: crate::PathOwned = crate::Path::new("from/peer").to_owned(); - let mut watch = Watched::new(crate::origin::Route::default().with_hops(hops)); - watch.deferred = true; - - let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests(None)); - ns.watched.insert(suffix.clone(), watch); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"cam") >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - publisher.sync_namespace(&mut ns, &suffix, &suffix).await.unwrap(); + // A cheaper route from B wins the selection. + let _from_b = origin + .announce( + "cam", + crate::origin::Route::default() + .with_epoch(epoch) + .with_hops(publisher_b) + .with_cost(0), + ) + .unwrap(); + let update = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + 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; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &update) >= 1 { + break; + } + settle().await; + } - assert!( - !ns.watched[&suffix].deferred, - "the retry timer stays armed for a namespace that can never be advertised" - ); + assert_eq!(occurrences(&log, &update), 1, "REQUEST_UPDATE carrying B's path"); + assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); + assert_eq!(log.bi_opens(), 1, "no withdrawal and no second stream"); } - /// A minimum wait binds every path back to the namespace, not just the retry sweep. - /// A route change re-prices the advertisement; it does not excuse us from the wait the - /// peer asked for. + /// moq-transport has no restart: another source winning a namespace without an epoch + /// withdraws it and advertises it afresh, so the peer resubscribes. #[moq_net_sim::test] - async fn a_route_change_still_waits_out_a_refusal() { - const VERSION: Version = Version::Draft17; + async fn a_restart_re_advertises_the_namespace() { + const VERSION: Version = Version::Draft19; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - // One epoch, so the second route re-prices the instance rather than restart it. - let epoch = crate::Epoch::mint(); - let cam = origin - .announce("solo-cam", crate::origin::Route::default().with_epoch(epoch.clone())) + // Forward the restart at once: the hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + let _old = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) .unwrap(); settle().await; - // Refused with a wait far longer than any backoff the loop would take on its own. - let refusal = publish_namespace_error(VERSION, 600_000).await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ok.clone(), ok]); let log = session.log.clone(); let publisher = Publisher::new( @@ -8854,84 +11084,44 @@ mod tests { origin.consume(), Control::new(None, false), None, - declared(Some(false)), + clustered(Some(false)), VERSION, ); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"solo-cam") > 0 { + if occurrences(&log, b"cam") >= 1 { break; } settle().await; } - assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - // A cheaper second route makes the advertisement worth re-pricing, which is a - // path back into the reconciliation that does not go through the retry timer. - let _standby = origin - .announce( - "solo-cam", - crate::origin::Route::default().with_epoch(epoch).with_cost(0), - ) + let _new = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) .unwrap(); - for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - tick().await; + if occurrences(&log, b"cam") >= 2 { + break; + } + settle().await; } - assert_eq!( - occurrences(&log, b"solo-cam"), - 1, - "re-offered inside the wait the peer asked for" - ); - - drop(cam); - } - - /// A SETUP slot for a peer that negotiated the cluster extension, so an - /// advertisement carries a path and a cost worth repricing. - fn clustered(solicit: Option) -> peer::PeerSetup { - let slot = peer::PeerSetup::default(); - slot.set(peer::Peer { - cluster: cluster::Peer { - hop: Some(crate::Hop::new(9).unwrap()), - cost: None, - }, - solicit, - hidden: false, - auth: false, - active_count: false, - }); - slot - } - - /// The bytes of one REQUEST_UPDATE, framed as the publisher writes it. - async fn request_update(version: Version, msg: &ietf::PublishNamespaceUpdate) -> 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::PublishNamespaceUpdate::ID).await.unwrap(); - writer.encode(msg).await.unwrap(); - log.writes.lock().unwrap().clone() + assert_eq!(occurrences(&log, b"cam"), 2, "advertised afresh"); + assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); } - /// A relay that starts carrying a namespace reprices it with REQUEST_UPDATE on the - /// request that already carries it, sending the changed parameter only. The new cost - /// is 0, which has to be explicit: REQUEST_UPDATE keeps an omitted parameter, so - /// leaving it out would keep the old price. + /// A peer that refuses an update closes the stream, which withdraws the + /// advertisement. The namespace is then not held at all, so it comes back as a fresh + /// PUBLISH_NAMESPACE once the refusal's wait is out, not as another update on a + /// stream the peer already ended. #[moq_net_sim::test] - async fn a_repricing_is_a_request_update() { + async fn a_refused_update_is_re_advertised_fresh() { const VERSION: Version = Version::Draft19; - // Forward the update at once: the hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - // One epoch, so the warm route re-prices the instance rather than restart it. + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let epoch = crate::Epoch::mint(); let _cold = origin .announce( @@ -8941,9 +11131,12 @@ mod tests { .unwrap(); settle().await; - // One stream: the PUBLISH_NAMESPACE request and its update, each answered OK. + // Stream 1 accepts the advertisement and refuses its update, with a wait shorter + // than the retry sweep; stream 2 accepts the fresh advertisement. let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let refusal = publish_namespace_error(VERSION, 50).await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), refusal].concat(), ok]); let log = session.log.clone(); let publisher = Publisher::new( @@ -8966,66 +11159,191 @@ mod tests { } assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - // We start carrying it: a free route outranks the cold one. let _warm = origin .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) .unwrap(); - // The request consumed id 1, so the update takes the next of our parity. The - // path is unchanged and omitted; the cost is an explicit 0. - let expected = request_update( + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"cam") >= 2 { + break; + } + tick().await; + } + + assert_eq!(occurrences(&log, b"cam"), 2, "re-advertised after the refusal"); + assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); + let update = request_update( VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }, ) .await; + assert_eq!(occurrences(&log, &update), 1, "only the one update was attempted"); + } + + /// Draft-17+ has no PUBLISH_NAMESPACE_DONE, so a withdrawal there is the FIN and + /// nothing else. Writing the message anyway puts its type on the wire before the body + /// fails to encode, which the receiver can only read as a protocol violation, so every + /// unannounce would kill an otherwise healthy session. + /// + /// Only reachable through the unsolicited loop, which is what this branch made the + /// default: the solicited path answers inline and never opens a request per namespace. + #[moq_net_sim::test] + async fn a_modern_withdrawal_is_the_fin_alone() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(None), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"solo-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); + + let advertised = log.writes.lock().unwrap().len(); + + // Unannounce, which retires the request the advertisement opened. + drop(cam); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + assert_eq!( + log.writes.lock().unwrap().len(), + advertised, + "a draft-17+ withdrawal wrote a message; the FIN alone retracts" + ); + } + + #[moq_net_sim::test] + async fn close_withdraws_legacy_namespaces_without_closing_the_origin() { + const VERSION: Version = Version::Draft14; + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("closing-cam", crate::origin::Route::default()).unwrap(); + settle().await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let log = session.log.clone(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(None), + VERSION, + ); + let mut run = std::pin::pin!(publisher.clone().run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &expected) >= 1 { + if occurrences(&log, b"closing-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"closing-cam"), 1); + assert!(!publisher.withdrawal.drained()); + publisher.withdrawal.begin(); + run.await.unwrap(); + assert!(publisher.withdrawal.drained()); + assert_eq!( + occurrences(&log, b"closing-cam"), + 2, + "PUBLISH_NAMESPACE_DONE names the withdrawn namespace" + ); + } + + /// The peer granting a stream is only half the exchange. One it accepts and then never + /// answers on wedges the loop exactly as a parked open does, so the response is bounded + /// too: everything queued behind it is otherwise stranded for the session. + /// + /// Draft-14 so each advertisement names its namespace on the wire. + #[moq_net_sim::test] + async fn a_silent_answer_still_lets_the_next_namespace_be_advertised() { + const VERSION: Version = Version::Draft14; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Every stream opens and then goes silent: an exhausted script parks rather than + // reporting EOF, which is the peer that takes the request and answers nothing. + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), Vec::new()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") > 0 && occurrences(&log, b"second-cam") > 0 { break; } - settle().await; + tick().await; } - assert_eq!(occurrences(&log, &expected), 1, "REQUEST_UPDATE with an explicit 0"); - assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); - assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); + // Whichever went first is the one that stalled, so both having reached the wire is + // the proof: the loop gave up on the answer and carried on. + assert!( + occurrences(&log, b"first-cam") > 0, + "the first advertisement never went out" + ); + assert!( + occurrences(&log, b"second-cam") > 0, + "the silent answer wedged the loop: the second namespace never went out" + ); } - /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK - /// to a REQUEST_UPDATE is the peer breaking the extension too. + /// Credit returning raises no signal of its own: no announce, no route change, nothing + /// the loop is watching. Only a retry brings the namespace back, and without one it + /// stays undiscoverable for the life of the session. #[moq_net_sim::test] - async fn a_counted_update_ok_is_a_violation() { - const VERSION: Version = Version::Draft19; + async fn a_namespace_refused_a_stream_is_retried_on_its_own() { + const VERSION: Version = Version::Draft14; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let epoch = crate::Epoch::mint(); - let _cold = origin - .announce( - "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), - ) - .unwrap(); + let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); settle().await; - // The PUBLISH_NAMESPACE is answered cleanly; its update's OK carries a count. - let counted = crate::lite::test_transport::Log::default(); - let mut writer = - crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(counted.clone()), VERSION); - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - request_id: None, - active: Some(0), - }) - .await - .unwrap(); - let counted = counted.writes.lock().unwrap().clone(); + // Closed from the start: the peer has granted nothing. + let gate = kio::Producer::new(false); let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, counted].concat()]); + let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok], gate.consume()); let log = session.log.clone(); let publisher = Publisher::new( @@ -9034,607 +11352,611 @@ mod tests { origin.consume(), Control::new(None, false), None, - clustered(Some(false)), + declared(Some(false)), VERSION, ); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + + // Well past the point where the open gives up. for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { + tick().await; + } + assert_eq!(occurrences(&log, b"lonely-cam"), 0, "advertised without a stream"); + + // Credit returns. Nothing else changes: no publish, no unannounce, no route move. + set_gate(&gate, true); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"lonely-cam") > 0 { break; } - settle().await; + tick().await; } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - - // Repricing sends the REQUEST_UPDATE whose OK is counted. - let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) - .unwrap(); + assert_eq!(occurrences(&log, b"lonely-cam"), 1, "never came back on its own"); + } - let res = moq_net_sim::timeout(Duration::from_secs(5), run) - .await - .expect("the violation ends the loop"); - assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); - // Closed at the source, since the solicited path's request task only logs errors. - let code = crate::SessionError::ProtocolViolation.to_code(); - assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); + /// Advance far enough that a parked open gives up and its retry comes due, without + /// making the test wait: time is paused, so this only moves the clock the loop reads. + async fn tick() { + moq_net_sim::advance(Duration::from_millis(200)).await; } - /// A route from a different original publisher under the same epoch (a replica) - /// updates the advertisement in place, like any other change within an instance: - /// withdrawing it would make the namespace briefly vanish downstream just because - /// its replica moved. - #[moq_net_sim::test] - async fn a_replica_change_is_a_request_update() { - const VERSION: Version = Version::Draft19; + fn set_gate(gate: &kio::Producer, open: bool) { + let Ok(mut gate) = gate.write() else { + panic!("gate closed") + }; + *gate = open; + } - // Forward the update at once: the hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - let publisher_a = crate::Hops::try_from(vec![crate::Hop::new(7).unwrap()]).unwrap(); - let publisher_b = crate::Hops::try_from(vec![crate::Hop::new(8).unwrap()]).unwrap(); - let epoch = crate::Epoch::mint(); - let _from_a = origin - .announce( - "cam", - crate::origin::Route::default() - .with_epoch(epoch.clone()) - .with_hops(publisher_a) - .with_cost(4), - ) - .unwrap(); - settle().await; + /// A publisher talking to a scripted peer that never answers, over one bidi stream. + struct Harness { + publisher: Publisher, + session: crate::lite::test_transport::ScriptedSession, + log: crate::lite::test_transport::Log, + /// Keeps the origin alive; the publisher only holds a consumer. + _origin: origin::Producer, + } - // One stream: the advertisement from A and its update to B, each answered OK. - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + fn harness(version: Version) -> Harness { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); let log = session.log.clone(); + // Serving a request blocks on the peer's SETUP, which no scripted peer sends here. + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let publisher = Publisher::new( crate::time::Clock::sim(), - session, + session.clone(), origin.consume(), Control::new(None, false), None, - clustered(Some(false)), - VERSION, + peer_setup, + version, ); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { - break; - } - settle().await; + Harness { + publisher, + session, + log, + _origin: origin, } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + } - // A cheaper route from B wins the selection. - let _from_b = origin - .announce( - "cam", - crate::origin::Route::default() - .with_epoch(epoch) - .with_hops(publisher_b) - .with_cost(0), + /// Subscribe to a path nothing publishes, returning what the peer would read off the + /// request stream plus the reset codes the stream recorded. + async fn subscribe_missing(version: Version) -> (Vec, Vec) { + let h = harness(version); + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + h.publisher + .clone() + .run_subscribe_stream( + stream, + ietf::Subscribe { + request_id: RequestId(1), + track_namespace: crate::Path::new("nothing/here"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + forward: true, + range_filters: false, + authorization_token: None, + }, + ) + .await + .unwrap(); + + let writes = h.log.writes.lock().unwrap().clone(); + (writes, h.log.resets()) + } + + /// Send a FETCH we refuse, returning the same pair. + async fn fetch_refused(version: Version, fetch_type: FetchType<'_>) -> (Vec, Vec) { + let h = harness(version); + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + h.publisher + .clone() + .run_fetch_stream( + stream, + ietf::Fetch { + request_id: RequestId(1), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + fetch_type, + range_filters: false, + fill_timeout: false, + properties_wanted: true, + }, ) + .await .unwrap(); - let update = request_update( - VERSION, - &ietf::PublishNamespaceUpdate { - request_id: RequestId(3), - hops: Some(cluster::HopPath::new( - crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), - )), - cost: Some(0), - }, - ) - .await; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &update) >= 1 { - break; - } - settle().await; + + let writes = h.log.writes.lock().unwrap().clone(); + (writes, h.log.resets()) + } + + /// A SUBSCRIBE for a path with no publisher is refused with REQUEST_ERROR, and the refusal + /// has to survive the trip. `Writer` resets the stream on drop, and a reset that races the + /// write discards the bytes the peer has not read yet, which leaves the subscriber waiting + /// on a request we already refused. Finishing first makes the drop-time reset a no-op. + #[moq_net_sim::test] + async fn missing_broadcast_is_refused_without_resetting_the_stream() { + for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { + let (writes, resets) = subscribe_missing(version).await; + + assert!(!writes.is_empty(), "{version}: nothing was sent"); + assert_eq!( + writes[0], + ietf::RequestError::ID as u8, + "{version}: not a REQUEST_ERROR" + ); + assert!(resets.is_empty(), "{version}: stream reset, discarding the error"); } - - assert_eq!(occurrences(&log, &update), 1, "REQUEST_UPDATE carrying B's path"); - assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); - assert_eq!(log.bi_opens(), 1, "no withdrawal and no second stream"); } - /// moq-transport has no restart: another source winning a namespace without an epoch - /// withdraws it and advertises it afresh, so the peer resubscribes. + /// Every FETCH we refuse goes out through its own error encoder, so it needs the same + /// finish: a reset there loses the rejection the same way. #[moq_net_sim::test] - async fn a_restart_re_advertises_the_namespace() { - const VERSION: Version = Version::Draft19; + async fn a_refused_fetch_does_not_reset_the_stream() { + let refused = || { + [ + ( + "standalone", + FetchType::Standalone { + namespace: crate::Path::new("nothing/here"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 1, object: 0 }, + }, + ), + ( + "empty range", + FetchType::Standalone { + namespace: crate::Path::new("nothing/here"), + track: "video".into(), + start: Location { group: 2, object: 0 }, + end: Location { group: 1, object: 0 }, + }, + ), + ] + }; - // Forward the restart at once: the hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { + for (label, fetch_type) in refused() { + let (writes, resets) = fetch_refused(version, fetch_type).await; + + assert!(!writes.is_empty(), "{version} {label}: nothing was sent"); + assert_eq!( + writes[0], + ietf::RequestError::ID as u8, + "{version} {label}: not a REQUEST_ERROR" + ); + assert!( + resets.is_empty(), + "{version} {label}: stream reset, discarding the error" + ); + } } - .produce(); - let _old = origin - .announce("cam", crate::origin::Route::default().with_cost(4)) - .unwrap(); - settle().await; + } - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ok.clone(), ok]); - let log = session.log.clone(); + /// A client configured with a request token puts it on the PUBLISH_NAMESPACE it sends, on + /// a legacy draft (draft-14 trailing block) and a strict one (draft-18 message + /// parameters). Without `with_request_token` the token bytes never reach the wire. + #[moq_net_sim::test] + async fn a_configured_request_token_rides_the_publish_namespace() { + let token = bytes::Bytes::from_static(&[0x03, 0x00, 0xfe, 0xed]); + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - clustered(Some(false)), - VERSION, - ); + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(version).await]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - let _new = origin - .announce("cam", crate::origin::Route::default().with_cost(0)) - .unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 2 { - break; + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut sent = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; } - settle().await; + assert!(sent, "{version}: the request token must ride the PUBLISH_NAMESPACE"); } - - assert_eq!(occurrences(&log, b"cam"), 2, "advertised afresh"); - assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); } - /// A peer that refuses an update closes the stream, which withdraws the - /// advertisement. The namespace is then not held at all, so it comes back as a fresh - /// PUBLISH_NAMESPACE once the refusal's wait is out, not as another update on a - /// stream the peer already ended. + /// Replacing the request token re-presents it on a live announce as a token-only + /// PUBLISH_NAMESPACE_UPDATE (MoQ request-token renewal), on the same stream, without a + /// reprice. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_refused_update_is_re_advertised_fresh() { - const VERSION: Version = Version::Draft19; + async fn setting_a_new_request_token_re_presents_it_on_a_live_announce() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let epoch = crate::Epoch::mint(); - let _cold = origin - .announce( - "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), - ) - .unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Stream 1 accepts the advertisement and refuses its update, with a wait shorter - // than the retry sweep; stream 2 accepts the fresh advertisement. - let ok = publish_namespace_ok(VERSION).await; - let refusal = publish_namespace_error(VERSION, 50).await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), refusal].concat(), ok]); + // One stream carries the announce: RequestOk answers the PUBLISH_NAMESPACE, then + // RequestOk answers the token-only REQUEST_UPDATE. + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - clustered(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + // The initial token rides the PUBLISH_NAMESPACE. + let mut initial = false; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { + if occurrences(&log, &first) >= 1 { + initial = true; break; } settle().await; } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - - let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) - .unwrap(); + assert!(initial, "the initial token must ride the PUBLISH_NAMESPACE"); + // Replacing it re-presents the new token on the live announce. + token.set(Some(typed(&second))); + let mut renewed = false; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 2 { + if occurrences(&log, &second) >= 1 { + renewed = true; break; } - tick().await; + settle().await; } - - assert_eq!(occurrences(&log, b"cam"), 2, "re-advertised after the refusal"); - assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); - let update = request_update( - VERSION, - &ietf::PublishNamespaceUpdate { - request_id: RequestId(3), - hops: None, - cost: Some(0), - }, - ) - .await; - assert_eq!(occurrences(&log, &update), 1, "only the one update was attempted"); + assert!(renewed, "a replaced token must be re-presented on the live announce"); } - /// Draft-17+ has no PUBLISH_NAMESPACE_DONE, so a withdrawal there is the FIN and - /// nothing else. Writing the message anyway puts its type on the wire before the body - /// fails to encode, which the receiver can only read as a protocol violation, so every - /// unannounce would kill an otherwise healthy session. - /// - /// Only reachable through the unsolicited loop, which is what this branch made the - /// default: the solicited path answers inline and never opens a request per namespace. + /// Setting the same token again is a no-op: no second REQUEST_UPDATE is sent, so the token + /// bytes appear once (the initial PUBLISH_NAMESPACE) and no more. #[moq_net_sim::test] - async fn a_modern_withdrawal_is_the_fin_alone() { - const VERSION: Version = Version::Draft17; + async fn an_unchanged_token_is_not_re_presented() { + const VERSION: Version = Version::Draft18; + let token_bytes = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&token_bytes))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(None), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"solo-cam") > 0 { + if occurrences(&log, &token_bytes) >= 1 { break; } settle().await; } - assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); - - let advertised = log.writes.lock().unwrap().len(); - - // Unannounce, which retires the request the advertisement opened. - drop(cam); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!( - log.writes.lock().unwrap().len(), - advertised, - "a draft-17+ withdrawal wrote a message; the FIN alone retracts" + occurrences(&log, &token_bytes), + 1, + "the initial token rode the announce once" ); - } - #[moq_net_sim::test] - async fn close_withdraws_legacy_namespaces_without_closing_the_origin() { - const VERSION: Version = Version::Draft14; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("closing-cam", crate::origin::Route::default()).unwrap(); - settle().await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); - let log = session.log.clone(); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - declared(None), - VERSION, - ); - let mut run = std::pin::pin!(publisher.clone().run_publish_namespaces()); - for _ in 0..100 { + // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. + token.set(Some(typed(&token_bytes))); + for _ in 0..50 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"closing-cam") > 0 { - break; - } settle().await; } - assert_eq!(occurrences(&log, b"closing-cam"), 1); - assert!(!publisher.withdrawal.drained()); - publisher.withdrawal.begin(); - run.await.unwrap(); - assert!(publisher.withdrawal.drained()); assert_eq!( - occurrences(&log, b"closing-cam"), - 2, - "PUBLISH_NAMESPACE_DONE names the withdrawn namespace" + occurrences(&log, &token_bytes), + 1, + "an unchanged token must not be re-presented" ); } - /// The peer granting a stream is only half the exchange. One it accepts and then never - /// answers on wedges the loop exactly as a parked open does, so the response is bounded - /// too: everything queued behind it is otherwise stranded for the session. - /// - /// Draft-14 so each advertisement names its namespace on the wire. + /// A refused token refresh (REQUEST_ERROR with a retry interval) withdraws the announce and + /// re-offers it fresh, carrying the new token, once the interval passes. Without re-arming the + /// retry sweep (which only revisits deferred namespaces) the withdrawn namespace would never + /// come back. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_silent_answer_still_lets_the_next_namespace_be_advertised() { - const VERSION: Version = Version::Draft14; + async fn a_refused_token_refresh_re_offers_the_namespace_with_the_new_token() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); - let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Every stream opens and then goes silent: an exhausted script parks rather than - // reporting EOF, which is the peer that takes the request and answers nothing. - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), Vec::new()]); + // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is + // refused with a 1s retry interval. Stream 2: the re-offer is accepted. + let mut stream1 = publish_namespace_ok(VERSION).await; + stream1.extend(publish_namespace_error(VERSION, 1000).await); + let stream2 = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..200 { + // Replace the token: the refresh REQUEST_UPDATE carries it and the peer refuses it, + // closing the stream. + token.set(Some(typed(&second))); + for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") > 0 && occurrences(&log, b"second-cam") > 0 { + if occurrences(&log, &second) >= 1 { break; } - tick().await; + settle().await; } + assert_eq!( + occurrences(&log, &second), + 1, + "the refused refresh carried the new token once" + ); - // Whichever went first is the one that stalled, so both having reached the wire is - // the proof: the loop gave up on the answer and carried on. + // Past the 1s retry interval, the withdrawn namespace is re-offered on a fresh stream, + // carrying the new token. + moq_net_sim::advance(Duration::from_secs(2)).await; + let mut reoffered = false; + for _ in 0..300 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 2 { + reoffered = true; + break; + } + settle().await; + } assert!( - occurrences(&log, b"first-cam") > 0, - "the first advertisement never went out" + reoffered, + "a refused refresh must re-offer the namespace with the new token after the interval" ); assert!( - occurrences(&log, b"second-cam") > 0, - "the silent answer wedged the loop: the second namespace never went out" + log.bi_opens() >= 2, + "the re-offer opened a fresh PUBLISH_NAMESPACE stream" ); } - /// Credit returning raises no signal of its own: no announce, no route change, nothing - /// the loop is watching. Only a retry brings the namespace back, and without one it - /// stays undiscoverable for the life of the session. + /// A token refresh the peer never answers drops the announce's stream and re-offers the + /// namespace fresh, carrying the new token. Without forgetting the sent advertisement, + /// reconcile would see it unchanged and the namespace would stay withdrawn. Draft-17+, + /// since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_namespace_refused_a_stream_is_retried_on_its_own() { - const VERSION: Version = Version::Draft14; + async fn an_unanswered_token_refresh_re_offers_the_namespace_with_the_new_token() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Closed from the start: the peer has granted nothing. - let gate = kio::Producer::new(false); - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok], gate.consume()); + // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is + // never answered. Stream 2: the re-offer is accepted. + let stream1 = publish_namespace_ok(VERSION).await; + let stream2 = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - - // Well past the point where the open gives up. for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - tick().await; + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; } - assert_eq!(occurrences(&log, b"lonely-cam"), 0, "advertised without a stream"); - - // Credit returns. Nothing else changes: no publish, no unannounce, no route move. - set_gate(&gate, true); + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); + token.set(Some(typed(&second))); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"lonely-cam") > 0 { + if occurrences(&log, &second) >= 1 { break; } - tick().await; + settle().await; } - assert_eq!(occurrences(&log, b"lonely-cam"), 1, "never came back on its own"); - } + assert_eq!( + occurrences(&log, &second), + 1, + "the unanswered refresh carried the new token once" + ); - /// Advance far enough that a parked open gives up and its retry comes due, without - /// making the test wait: time is paused, so this only moves the clock the loop reads. - async fn tick() { - moq_net_sim::advance(Duration::from_millis(200)).await; + // Past the answer timeout, the namespace is re-offered on a fresh stream with the new token. + moq_net_sim::advance(ADVERTISE_TIMEOUT + Duration::from_secs(1)).await; + let mut reoffered = false; + for _ in 0..300 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 2 { + reoffered = true; + break; + } + settle().await; + } + assert!( + reoffered, + "an unanswered refresh must re-offer the namespace with the new token" + ); + assert!( + log.bi_opens() >= 2, + "the re-offer opened a fresh PUBLISH_NAMESPACE stream" + ); } - fn set_gate(gate: &kio::Producer, open: bool) { - let Ok(mut gate) = gate.write() else { - panic!("gate closed") - }; - *gate = open; - } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged + /// token: a receiver renewing a token-authorized announce answers a token-bearing update as + /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. + #[moq_net_sim::test] + async fn a_reprice_does_not_carry_the_request_token() { + const VERSION: Version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - /// A publisher talking to a scripted peer that never answers, over one bidi stream. - struct Harness { - publisher: Publisher, - session: crate::lite::test_transport::ScriptedSession, - log: crate::lite::test_transport::Log, - /// Keeps the origin alive; the publisher only holds a consumer. - _origin: origin::Producer, - } + // Forward the reprice at once: the update hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + // One epoch, so the warm route re-prices the instance rather than restart it. + let epoch = crate::Epoch::mint(); + let _cold = origin + .announce( + "cam", + crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + ) + .unwrap(); + settle().await; - fn harness(version: Version) -> Harness { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); let log = session.log.clone(); - // Serving a request blocks on the peer's SETUP, which no scripted peer sends here. - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let publisher = Publisher::new( crate::time::Clock::sim(), - session.clone(), + session, origin.consume(), Control::new(None, false), None, - peer_setup, - version, - ); + clustered(Some(false)), + VERSION, + ) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - Harness { - publisher, - session, - log, - _origin: origin, + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + break; + } + settle().await; } - } - - /// Subscribe to a path nothing publishes, returning what the peer would read off the - /// request stream plus the reset codes the stream recorded. - async fn subscribe_missing(version: Version) -> (Vec, Vec) { - let h = harness(version); - - let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); - h.publisher - .clone() - .run_subscribe_stream( - stream, - ietf::Subscribe { - request_id: RequestId(1), - track_namespace: crate::Path::new("nothing/here"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, - fill: None, - properties_wanted: true, - forward: true, - range_filters: false, - }, - ) - .await - .unwrap(); - - let writes = h.log.writes.lock().unwrap().clone(); - (writes, h.log.resets()) - } - - /// Send a FETCH we refuse, returning the same pair. - async fn fetch_refused(version: Version, fetch_type: FetchType<'_>) -> (Vec, Vec) { - let h = harness(version); + assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); - let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); - h.publisher - .clone() - .run_fetch_stream( - stream, - ietf::Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - }, - ) - .await + // The announce has gone out carrying the token exactly once; a reprice must then emit a + // PUBLISH_NAMESPACE_UPDATE that does NOT re-send the token. + let after_announce = log.writes.lock().unwrap().len(); + let _warm = origin + .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) .unwrap(); - - let writes = h.log.writes.lock().unwrap().clone(); - (writes, h.log.resets()) - } - - /// A SUBSCRIBE for a path with no publisher is refused with REQUEST_ERROR, and the refusal - /// has to survive the trip. `Writer` resets the stream on drop, and a reset that races the - /// write discards the bytes the peer has not read yet, which leaves the subscriber waiting - /// on a request we already refused. Finishing first makes the drop-time reset a no-op. - #[moq_net_sim::test] - async fn missing_broadcast_is_refused_without_resetting_the_stream() { - for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { - let (writes, resets) = subscribe_missing(version).await; - - assert!(!writes.is_empty(), "{version}: nothing was sent"); - assert_eq!( - writes[0], - ietf::RequestError::ID as u8, - "{version}: not a REQUEST_ERROR" - ); - assert!(resets.is_empty(), "{version}: stream reset, discarding the error"); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if log.writes.lock().unwrap().len() > after_announce { + break; + } + settle().await; } + assert!( + log.writes.lock().unwrap().len() > after_announce, + "the reprice update was never sent" + ); + assert_eq!( + occurrences(&log, &token), + 1, + "the reprice must not carry the request token: it rides only the initial announce" + ); } - /// Every FETCH we refuse goes out through its own error encoder, so it needs the same - /// finish: a reset there loses the rejection the same way. - #[moq_net_sim::test] - async fn a_refused_fetch_does_not_reset_the_stream() { - let refused = || { - [ - ( - "standalone", - FetchType::Standalone { - namespace: crate::Path::new("nothing/here"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 1, object: 0 }, - }, - ), - ( - "empty range", - FetchType::Standalone { - namespace: crate::Path::new("nothing/here"), - track: "video".into(), - start: Location { group: 2, object: 0 }, - end: Location { group: 1, object: 0 }, - }, - ), - ] - }; - - for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { - for (label, fetch_type) in refused() { - let (writes, resets) = fetch_refused(version, fetch_type).await; - - assert!(!writes.is_empty(), "{version} {label}: nothing was sent"); - assert_eq!( - writes[0], - ietf::RequestError::ID as u8, - "{version} {label}: not a REQUEST_ERROR" - ); - assert!( - resets.is_empty(), - "{version} {label}: stream reset, discarding the error" - ); - } + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), } } } @@ -9892,6 +12214,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..df8d2850e8 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -78,6 +78,18 @@ 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 { @@ -87,6 +99,8 @@ pub(super) struct Update { /// Why the update is refused, which fails the subscription: [`Error::Unsupported`], or a /// [`DecodeError::MalformedAuthToken`] for tokens that repeat. pub(super) refused: Option, + /// The AUTHORIZATION TOKEN renewing the request's credential. + pub(super) authorization_token: Option, } impl Update { @@ -122,6 +136,7 @@ impl Update { request_id, priority: Some(priority), refused: Self::refused(&tokens, !forward), + authorization_token: tokens.into_iter().next(), }); } // The request it updates, which the adapter already routed by. @@ -156,7 +171,6 @@ 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() @@ -167,6 +181,7 @@ impl Update { request_id, priority, refused: Self::refused(&token, unsupported), + authorization_token: token.into_iter().next(), }) } 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..4a7b96cdcb --- /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: crate::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(err.to_code(), "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 96e952f0ee..9c85294050 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}, }; @@ -133,6 +133,7 @@ where early_unis, extensions, } = config; + let request_token = auth.request_token(); // GOAWAY wiring: the public Session holds one half (drain trigger, received // signal), the protocol tasks below hold the other. @@ -226,7 +227,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -246,6 +248,7 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) + .with_request_token(request_token.clone()) .with_solicit(extensions.solicit); subscriber.announces = announces; @@ -413,9 +416,11 @@ where let auth = auth.clone(); let origin = publish.clone(); let session = session.clone(); + let request_token = request_token.clone(); + let peer_setup = peer_setup.clone(); async move { match client { - true => enforce_grant(auth, origin, session).await, + true => enforce_grant(auth, origin, session, request_token, peer_setup, version).await, false => std::future::pending().await, } } @@ -429,7 +434,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -449,6 +455,7 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) + .with_request_token(request_token.clone()) .with_solicit(extensions.solicit); // Our tokens, one Auth request each, once the peer's SETUP negotiates it. @@ -776,6 +783,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?; @@ -1405,7 +1413,18 @@ async fn enforce_grant( auth: crate::auth::Handle, origin: origin::Consumer, mut session: S, + request_token: crate::RequestToken, + peer_setup: peer::PeerSetup, + version: Version, ) -> Result<(), Error> { + // A request token authorizes an announce outside the connection grant only when the + // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on + // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every + // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still + // bounds what we publish. + if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { + return Ok(()); + } let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { @@ -1420,6 +1439,16 @@ async fn enforce_grant( Err(err) } +/// Whether our announces will ride their own PUBLISH_NAMESPACE requests, the only form that +/// carries a request token, rather than inline NAMESPACE entries. Draft-14/15 predate +/// NAMESPACE; later drafts send requests unless the peer requires solicitation. +async fn announces_carry_token(peer_setup: &peer::PeerSetup, version: Version) -> bool { + match version { + Version::Draft14 | Version::Draft15 => true, + _ => !peer_setup.get().await.solicit.unwrap_or(false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1883,6 +1912,91 @@ mod tests { /// parks again; a busy machine cannot turn a slow announce into a passing silence. const ANNOUNCE_TURNS: usize = 100; + /// A client that presents a request token authorizes each announce at the server when the + /// announce carries the token, so dialing-side grant enforcement stands down for a peer + /// that takes unsolicited PUBLISH_NAMESPACE requests: enforcing it would close the client + /// for announcing outside the connection grant the token was meant to extend. + #[moq_net_sim::test] + async fn a_client_may_send_a_token_bearing_request_its_connection_grant_does_not_cover() { + let auth = crate::auth::Handle::new(true); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("room/alice", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(false), + ..Default::default() + }); + + let result = enforce_grant( + auth, + origin.consume(), + session, + crate::RequestToken::new(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })), + peer_setup, + Version::Draft18, + ) + .await; + + assert!( + result.is_ok(), + "a token-bearing client must not be closed by grant enforcement" + ); + assert!( + log.closes().is_empty(), + "the session must stay open for a token-bearing client" + ); + } + + /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, + /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client + /// advertise outside its connection grant there: the grant is still enforced. + #[moq_net_sim::test] + async fn a_request_token_does_not_lift_the_grant_when_announces_are_inline() { + let auth = crate::auth::Handle::new(true); + let setup = auth.present(bytes::Bytes::new(), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("room/alice").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin + .announce("room/bob/cam", crate::origin::Route::default()) + .unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + + let result = enforce_grant( + auth, + origin.consume(), + session, + crate::RequestToken::new(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })), + peer_setup, + Version::Draft18, + ) + .await; + + assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); + assert_eq!(log.closes().len(), 1, "the session closes on the uncovered announce"); + drop(setup); + } + /// Run a publish-only session against a peer that declared `peer_declared`, returning /// how many times the namespace reached the wire. /// @@ -2523,6 +2637,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } .encode(&mut encoder, VERSION) .unwrap(); @@ -2830,6 +2945,7 @@ mod tests { request_id: RequestId(1), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }) .await .unwrap(); @@ -2939,6 +3055,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..a8035631fb 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. The parameter may repeat; the + /// first instance is the request's credential, and every instance has been decoded. + pub authorization_token: Option, } /// NEW_GROUP_REQUEST (0x32) arrived in draft-16. @@ -69,7 +74,9 @@ 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)?; + super::token::unique(&tokens)?; + let authorization_token = tokens.into_iter().next(); Ok(Self { request_id, @@ -82,12 +89,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, @@ -143,6 +150,7 @@ impl Message for Subscribe<'_> { // Absent means 1. forward: forward.unwrap_or(true), range_filters, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -163,7 +171,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 +187,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 +382,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. The + /// parameter may repeat; the first instance is kept, and every instance has been decoded. + pub authorization_token: Option, } impl Message for SubscribeUpdate { @@ -389,9 +410,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 +422,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 +439,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 +457,11 @@ 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)?; + super::token::unique(&tokens)?; + let authorization_token = tokens.into_iter().next(); Ok(Self { request_id, @@ -443,6 +470,8 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + filter: None, + authorization_token, }) } Version::Draft15 | Version::Draft16 => { @@ -450,16 +479,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 +493,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: authorization_token.into_iter().next(), }) } _ => { @@ -479,11 +507,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 +521,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 +528,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -538,6 +565,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -562,6 +590,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -656,6 +685,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 +717,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -873,6 +904,7 @@ mod tests { properties_wanted: wanted, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, version); @@ -907,8 +939,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 +950,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 +961,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 +974,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 +1033,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1031,8 +1068,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 +1079,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 +1096,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1233,8 +1273,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 +1284,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 +1298,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() }; @@ -1318,6 +1362,7 @@ mod tests { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }; #[rustfmt::skip] @@ -1379,6 +1424,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..1fc96af1fc 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -465,6 +465,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 3f0f48ff62..5000803f72 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; @@ -616,6 +616,10 @@ pub(super) struct Subscriber { frames: frame::Budget, // Namespaces the peer may have announced at once (`session::Limits::announces`). pub(super) announces: crate::session::Slots, + // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests and their + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } /// The prefixes to issue SUBSCRIBE_NAMESPACE for: `origin`'s permitted scope, @@ -701,6 +705,7 @@ where frames: Default::default(), announces: Default::default(), validity: Default::default(), + request_token: crate::RequestToken::default(), } } @@ -720,6 +725,15 @@ where self } + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE + /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ + /// way (MoQ request-token). A shared handle, so a replaced token is re-presented on each + /// live subscription as a REQUEST_UPDATE. + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { + self.request_token = token; + self + } + /// Follow the session's limit, attaching every withheld namespace a new limit /// covers. Each route's own serve task holds itself back when a limit no longer /// covers it. @@ -1277,6 +1291,37 @@ where return Ok(()); }; + // A request token on the PUBLISH_NAMESPACE authorizes the announce when the session + // grant does not already cover it (MoQ request-token, draft-17 section 9.3.2 / + // draft-18+ section 10.2.2). Purely additive: with no token, or a grant that covers + // the path, everything below is unchanged and the origin model decides scope as it + // did before. + let mut token_grant = None; + if let Some(token) = &msg.authorization_token + && !self.auth.covers(crate::auth::Direction::Subscribe, path.as_str()) + { + // The token's grant must cover this announce; it authorizes nothing else and never + // joins the session union. + let admitted = self + .auth + .admit_request( + &self.runtime, + token.0.clone(), + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ) + .await; + match admitted { + Ok(grant) => token_grant = Some(grant), + Err(err) => { + self.write_error(&mut stream, request_id, &err, &err.to_string()) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + } + } + match self.start_announce(path.clone(), advert) { Ok(_) => { if let Err(err) = self.write_ok(&mut stream, request_id).await { @@ -1301,7 +1346,7 @@ where // 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) + .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached, token_grant) .await; if attached { @@ -1341,35 +1386,148 @@ where mut held: Option, peer: cluster::Peer, attached: &mut bool, + mut token_grant: Option, ) -> Result<(), Error> { + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant (MoQ + // request-token); its verify is raced against the stream and the old grant's deadline in + // the loop's single wait (never a bare await), so the old deadline still fires while a + // slow acceptor decides and a peer ending the announce ends it whatever the acceptor + // 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), + // The request grant lapsed or was revoked: the announce ends, never the session. + Ended(Error), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(request_update::Verdict), + } + 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 Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Turn::Ended(err)); + } + 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) => { + 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) => { + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } + } } - 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(()); } // The advertisement outlives the peer's send direction. + finished = true; + continue; + } + // Already finished: the advertisement outlived the peer's send direction until now. + Turn::WriterClosed(result) if finished => return result, + Turn::WriterClosed(result) => { + stream.reader.abort(&Error::Cancel); + result?; + if super::request_stream::fin_cancels(self.version) { + return Ok(()); + } return stream.writer.closed().await; } + Turn::Failed(err) => return Err(err), + Turn::Ended(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_mut().expect("a renewal without a request grant"); + // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). + if rg.renew(verdict, res) { + 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. + self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") + .await?; + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } }; + // 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?; continue; } let terminal = self.terminal_publish_namespace(type_id); @@ -1380,7 +1538,6 @@ where return Err(Error::UnexpectedMessage); } - let body: ietf::Body = stream.reader.decode().await?; let mut data = body.decoder(self.version); if terminal { @@ -1420,54 +1577,76 @@ 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()); + } + 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 => self.start_announce(path.clone(), advert).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; - } - return Ok(()); + // An unroutable apply withdraws the announce whether or not a token also rides it. + if let Err(err) = &applied { + 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; } + return Ok(()); + } + + // 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 + && token_grant.is_some() + { + let verdict = + self.auth + .verify_request(token.0, path.clone(), crate::auth::RequestKind::PublishNamespace); + renewal.verify(verdict, msg.request_id); + continue; } + + // No token rides this update: acknowledge it now. + self.write_ok(stream, msg.request_id).await?; } } @@ -1973,15 +2152,29 @@ where // 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()) - { + // does, leaving the rest of the session alone. A SUBSCRIBE always carries the token, so + // a token-bearing client does not self-censor on its connection grant: the token + // authorizes what that grant does not cover, and the server's covers-gate is the + // authority. The token stands in for the grant only, never for the limit this side set + // on the peer, which still filters and, narrowing, revokes. + let token_authorized = self.request_token.peek().is_some(); + let allowed = match token_authorized { + true => self + .auth + .within_limit(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + false => self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + }; + if !allowed { request.reject(Error::Unauthorized); return; } - let mut gate = crate::auth::Gate::new( + let gate_for = match token_authorized { + true => crate::auth::Gate::limit, + false => crate::auth::Gate::new, + }; + let mut gate = gate_for( self.auth.clone(), broadcast_path.to_owned(), crate::auth::Direction::Subscribe, @@ -2107,6 +2300,8 @@ where let aggregate = target.subscription(); let priority = aggregate.as_ref().map(|s| s.priority).unwrap_or(0); let order = aggregate.map(|s| s.order).unwrap_or_default(); + // Re-sent unchanged on a draft-14 request-token renewal, whose fields are all mandatory. + let subscriber_priority = super::priority::to_wire(priority); let written = async { stream.writer.varint(ietf::Subscribe::ID).await?; stream @@ -2115,7 +2310,7 @@ where request_id, track_namespace: broadcast_path.to_owned(), track_name: track_name.into(), - subscriber_priority: super::priority::to_wire(priority), + subscriber_priority, group_order: order.into(), filter: join.filter, fill: join.fill, @@ -2123,6 +2318,7 @@ where properties_wanted: matches!(target, Target::Request(_)), forward: true, range_filters: false, + authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), }) .await } @@ -2285,6 +2481,12 @@ where priority, largest, } = accepted; + // Where this subscription began: the object after the Largest Object SUBSCRIBE_OK + // named, which every draft before 20 subscribes at. Draft-14's renewal restates it. + let renewal_start = largest.map_or(ietf::Location { group: 0, object: 0 }, |largest| ietf::Location { + group: largest.group, + object: largest.object.saturating_add(1), + }); // A resumed copy opts out of the properties from draft-20, so it keeps the units it // learned. Before that the answer declares them again. let timescale = timescale.or(resumed.as_ref().and_then(|idle| idle.timescale)); @@ -2371,6 +2573,11 @@ where enum End { Idle, Revoked, + /// The client replaced its request token: re-present it on this live subscription. + Renew(Option), + /// The publisher answered the renewal in flight (REQUEST_OK / REQUEST_ERROR), so + /// a replacement coalesced behind it may now be sent. + Answered, Done(Result), Fetch(group::Request), } @@ -2381,8 +2588,25 @@ where let demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); + // The request token last presented on this subscription (its initial value). + let mut last_token = self.request_token.peek(); + // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement + // that arrives while one is in flight is coalesced into `last_token` and sent once + // the outstanding one is answered, so the sender never outruns the receiver's + // MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7) whatever its value, without + // reading it. `in_flight` is the token value awaiting an answer, `None` when nothing + // is outstanding. Draft-14 answers no accepted renewal, so it cannot pace on answers + // and re-presents each change directly; it also advertises no credit to exceed. + let throttle = !matches!(self.version, Version::Draft14); + // The token awaiting an answer, and on drafts 15 and 16 the routing that brings the + // answer back here. + let mut in_flight: Option<(crate::setup::Token, Option)> = None; + // Bumped by `read_publish_done` on each renewal answer; the loop releases the + // coalesced replacement when it advances past `seen_answers`. + let answers = kio::Shared::new(0u64); + let mut seen_answers = 0u64; let idle = { - let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version, &answers)); loop { let end = kio::wait(|waiter| { if !fetch_done @@ -2416,11 +2640,80 @@ where if !subscribed || demand.poll_unused(waiter).is_ready() { return Poll::Ready(End::Idle); } + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(End::Renew(token)); + } + // Only wait on an answer while a renewal is actually outstanding. + if throttle + && in_flight.is_some() + && let Poll::Ready(count) = answers.poll(waiter, |count| match **count != seen_answers { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + seen_answers = *count; + return Poll::Ready(End::Answered); + } waiter.poll_future(done.as_mut()).map(End::Done) }) .await; match end { + // A replaced token is re-presented as a token-only REQUEST_UPDATE; the read + // future above consumes the answer. The subscribe stream's writer is a + // disjoint borrow from its reader, so writing here does not disturb the read. + End::Renew(token) => { + last_token = token.clone(); + // Send only when no renewal is outstanding; otherwise coalesce, leaving the + // newest in `last_token` to go out on End::Answered. A cleared token (`None`) + // is not a renewal and sends nothing. + let send_now = !throttle || in_flight.is_none(); + if send_now && let Some(token) = token { + match self + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) + .await + { + Ok(follow_up) if throttle => { + in_flight = Some((token, follow_up)); + // Only an answer that arrives after this send releases the + // coalesced follow-up, so a stray earlier answer cannot. + seen_answers = *answers.lock(); + } + Ok(_) => {} + // A failed send does not end the subscription: it continues on the old + // grant until that lapses, and the next change re-presents the token. + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } + } + } + End::Answered => { + // The outstanding renewal was answered. If the credential changed while it + // was in flight, present the newest now (a cleared token sends nothing); + // otherwise the publisher already holds the latest. + let was = in_flight.take().map(|(token, _)| token); + if last_token != was + && let Some(token) = last_token.clone() + { + match self + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) + .await + { + Ok(follow_up) => in_flight = Some((token, follow_up)), + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } + } + } End::Revoked => { tracing::info!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "subscription no longer authorized"); let _ = track.clone().abort(Error::Unauthorized); @@ -2621,16 +2914,96 @@ where /// /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN /// without one is a failed request, not a clean end. - async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { - match reader.varint_maybe().await? { - Some(ietf::PublishDone::ID) => {} - Some(_) => return Err(Error::UnexpectedMessage), - None => return Err(Error::ProtocolViolation), + /// + /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream + /// (REQUEST_OK / REQUEST_ERROR on draft-15+; draft-14 is silent on an accepted renewal and + /// answers a refused one with SUBSCRIBE_ERROR). Each answer bumps `answers` so the send + /// loop can release the renewal it coalesced behind the one in flight; the read continues + /// regardless, since the subscription ends on its PUBLISH_DONE, which the publisher sends + /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. + async fn read_publish_done( + reader: &mut Reader, + version: Version, + answers: &kio::Shared, + ) -> Result { + loop { + match reader.varint_maybe().await? { + Some(ietf::PublishDone::ID) => { + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version)?; + return Ok(msg.stream_count); + } + Some(ietf::RequestOk::ID) => { + let msg: ietf::RequestOk = reader.decode().await?; + tracing::debug!(message = ?msg, "request token renewal accepted"); + *answers.lock() += 1; + } + Some(ietf::RequestError::ID) => { + // Draft-15+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; + // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the + // renewal; the subscription then ends on the PUBLISH_DONE that follows, not + // on this answer. + match version { + Version::Draft14 => { + let msg: ietf::SubscribeError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + _ => { + let msg: ietf::RequestError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + } + *answers.lock() += 1; + } + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), + } } - let msg: ietf::PublishDone = reader.decode().await?; - tracing::debug!(message = ?msg, "received publish done"); - msg.end(version)?; - Ok(msg.stream_count) + } + + /// Re-present the client's request token on a live subscription as a token-only + /// REQUEST_UPDATE (SUBSCRIBE_UPDATE), so a refreshed credential reaches the publisher + /// before the old grant lapses (MoQ request-token renewal). + /// + /// Token-only: draft-15+ omits the range, priority and forward flag, which an update keeps + /// as they are. Draft-14's fields are fixed, so it restates the subscription's own: + /// `start` is where it began (the Largest Object after SUBSCRIBE_OK), which the peer + /// must not see decrease. The answer, if the version sends one, is read on the + /// subscription stream by [`read_publish_done`](Self::read_publish_done); on drafts 15 + /// and 16 the returned guard routes it there until dropped. + async fn send_request_token_update( + &self, + writer: &mut crate::coding::Writer, + subscription_id: RequestId, + subscriber_priority: u8, + start: ietf::Location, + token: crate::setup::Token, + ) -> Result, Error> { + let request_id = self.control.next_request_id(&self.runtime).await?; + // Drafts 15 and 16 answer naming the update itself; draft 14 answers no accepted + // update, and later drafts answer on the subscription's own stream. + let follow_up = matches!(self.version, Version::Draft15 | Version::Draft16) + .then(|| self.control.follow_up(request_id, subscription_id)); + // Draft-14/15/16 name the subscription being updated; draft-17+ identifies it by stream. + let subscription_request_id = match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(subscription_id), + _ => None, + }; + let draft14 = self.version == Version::Draft14; + writer + .encode_message(&ietf::SubscribeUpdate { + request_id, + subscription_request_id, + start_location: start, + end_group: 0, + subscriber_priority: draft14.then_some(subscriber_priority), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(crate::ietf::token::RequestToken(token)), + }) + .await?; + Ok(follow_up) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -4611,7 +4984,8 @@ mod tests { let mut session = ScriptedSession::eof(responses); let (_, recv) = session.open_bi().await.unwrap(); let mut reader = Reader::new(recv, Version::Draft19); - let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; + let result = + Subscriber::::read_publish_done(&mut reader, Version::Draft19, &kio::Shared::new(0)).await; if clean { assert_eq!(result.unwrap(), 0); } else { @@ -6705,6 +7079,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, @@ -6767,6 +7142,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) @@ -6823,6 +7199,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) @@ -7133,6 +7510,7 @@ mod tests { request_id: RequestId(3 + 2 * i as u64), hops: Some(advert.hops.clone()), cost: Some(advert.cost), + authorization_token: None, }) .await .unwrap(); @@ -7266,6 +7644,7 @@ mod tests { Some(clean), peer, &mut attached, + None, ) .await .expect_err("a repeated update ID must close the session"); @@ -7296,6 +7675,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..100 { @@ -7342,6 +7722,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); // Both updates apply, then the loop parks on the exhausted script. The @@ -7388,6 +7769,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }) .await .unwrap(); @@ -7409,6 +7791,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"); @@ -7457,6 +7840,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()) { @@ -7503,6 +7887,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!( @@ -7543,6 +7928,7 @@ mod tests { cost: 0, ..clean.clone() }), + authorization_token: None, }) .await .unwrap(); @@ -7561,6 +7947,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); let mut result = None; for _ in 0..20 { @@ -7700,6 +8087,1232 @@ mod tests { ); } } + + /// A SUBSCRIBE_OK for the subscribe stream, framed as the peer sends it, with a Largest so + /// the subscription reaches Established. The scripted session parks after it, keeping the + /// subscription live so the steady-state loop runs. + async fn subscribe_ok_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::SubscribeOk::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeOk { + request_id: match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(1)), + _ => None, + }, + track_alias: 7, + largest: Some(ietf::Location { group: 0, object: 0 }), + properties: Default::default(), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Replacing the client's request token re-presents it on a live subscription as a token-only + /// SUBSCRIBE_UPDATE (MoQ request-token renewal), on a legacy draft (draft-14 trailing block) + /// and a strict one (draft-18 message parameters). SUBSCRIBE_UPDATE carries the token from + /// draft-14 on, unlike PUBLISH_NAMESPACE_UPDATE (draft-17+ only). + #[moq_net_sim::test] + async fn setting_a_new_request_token_re_presents_it_on_a_live_subscription() { + for version in [Version::Draft14, Version::Draft18] { + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(version).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(typed(&first))); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_request_token(token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + // Held for the test so the track never reads as unused (which would cancel it). + let subscription = track.subscribe(None); + 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; + }); + + // The initial token rides the SUBSCRIBE. + let mut initial = false; + for _ in 0..200 { + if occurrences(&log, &first) >= 1 { + initial = true; + break; + } + settle().await; + } + assert!(initial, "{version}: the initial token must ride the SUBSCRIBE"); + + // Replacing it re-presents the new token on the live subscription as a SUBSCRIBE_UPDATE. + token.set(Some(typed(&second))); + let mut renewed = false; + for _ in 0..200 { + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!( + renewed, + "{version}: a replaced token must be re-presented on the live subscription" + ); + + // The renewal leaves the subscription's range alone: draft-14 restates where it + // began (the object after SUBSCRIBE_OK's Largest Object, {0, 0} here), and draft-15+ + // omits the filter, priority and forward flag so they keep their values. + let draft14 = version == Version::Draft14; + let mut expected = Vec::new(); + for request_id in 0..16 { + 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::SubscribeUpdate::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeUpdate { + request_id: RequestId(request_id), + subscription_request_id: draft14.then_some(RequestId(1)), + start_location: ietf::Location { group: 0, object: 1 }, + end_group: 0, + // The SUBSCRIBE went out at the lowest wire priority; the renewal restates it. + subscriber_priority: draft14.then_some(0xff), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(crate::ietf::token::RequestToken(typed(&second))), + }) + .await + .unwrap(); + expected.push(log.writes.lock().unwrap().clone()); + } + assert!( + expected.iter().any(|bytes| occurrences(&log, bytes) == 1), + "{version}: the renewal must keep the subscription's range" + ); + + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + } + } + + /// A token-bearing client does not self-censor on its connection grant: the + /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the + /// token; a token-less client with the same grant rejects it locally, as before. + #[moq_net_sim::test] + async fn a_token_bearing_client_subscribes_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "room/x". Held for the run. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + 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 { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + (auth, cred) + } + assert!( + !seed_auth().0.allows(crate::auth::Direction::Subscribe, "room/x"), + "the connection grant must not cover the subscribed path" + ); + + // With a token, the SUBSCRIBE for room/x reaches the wire carrying the token. + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let (auth, _cred) = seed_auth(); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let serving = moq_net_sim::spawn(async move { + subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; + }); + let mut sent = false; + for _ in 0..200 { + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; + } + assert!( + sent, + "a token-bearing client must subscribe outside its connection grant" + ); + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + + // Without a token, the same grant rejects the subscribe locally: no SUBSCRIBE is sent. + let session2 = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log2 = session2.log.clone(); + let (tasks2, _task_set2) = crate::util::TaskSet::new(); + let (auth2, _cred2) = seed_auth(); + let mut subscriber2 = Subscriber::new( + crate::time::Clock::sim(), + session2, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks2, + Default::default(), + ) + .with_auth(auth2); + let producer2 = crate::broadcast::Info::default().produce(); + let mut dynamic2 = producer2.dynamic(); + let consumer2 = producer2.consume(); + let track2 = consumer2.track("video").unwrap(); + let subscription2 = track2.subscribe(None); + let request2 = dynamic2.requested_track().await.expect("no track requested"); + let serving2 = moq_net_sim::spawn(async move { + subscriber2.run_subscribe(Path::new("room/x"), dynamic2, request2).await; + }); + for _ in 0..80 { + settle().await; + } + assert!( + !control_message_types(&log2, VERSION).contains(&ietf::Subscribe::ID), + "a token-less client rejects a subscribe outside its grant locally" + ); + drop(subscription2); + drop(track2); + drop(consumer2); + serving2.abort(); + } + + /// 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: crate::lite::test_transport::ScriptedSession, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + 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 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, + }, + ); + assert!( + !auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "the session grant 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()), + } + } + + /// 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: 300, + 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), + 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), + 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"); + } + + /// 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" + ); + } + + /// 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() { + const VERSION: Version = 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" + ); + } + + /// 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, 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() { + const VERSION: Version = 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() + ); + } + /// 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), + 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" + ); + } + + /// Like [`auth_announce_harness`] but the session never negotiated the MoQ Auth extension, + /// so its union is `None` forever: `allows` is permissive, `covers` is not. No credential + /// is presented; the acceptor answers request tokens regardless. + fn auth_announce_harness_no_ext( + version: Version, + ) -> ( + 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 session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + 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(false); + let requests = auth.requests().unwrap(); + assert!( + auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "a None union is permissive for allows" + ); + assert!( + !auth.covers(crate::auth::Direction::Subscribe, "room/alice"), + "a None union does not cover" + ); + + 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 the union is `None` forever, so `allows` is + /// permissive; a token-bearing PUBLISH_NAMESPACE must still be verified (`covers`), not + /// admitted by that default. Admitted on a covering grant; refused UNAUTHORIZED (not + /// admitted) on refusal. This is the standard moq-transport peer shape the quest targets. + #[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); + 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); + 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 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); + 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); + 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 + } + } + + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), + } + } } /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 615508d279..6f264b15f6 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -44,9 +44,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)?) @@ -266,6 +273,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/lib.rs b/rs/moq-net/src/lib.rs index c57d0cd9f5..c923e8c748 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -104,6 +104,7 @@ pub mod stats; pub mod time; pub mod transport; +pub(crate) use auth::RequestToken; pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, varint}; pub use driver::Driver; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 86d2c1efc1..04953f580c 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1220,6 +1220,56 @@ 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 acceptor. + #[moq_net_sim::test] + async fn a_pre_ok_requests_consumer_governs_the_session_acceptor() { + 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 acceptor 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: 1, + value: b"jwt".to_vec(), + }, + crate::PathOwned::from("room/alice".to_string()), + crate::auth::RequestKind::PublishNamespace, + ); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); + let scope = received.scope().expect("a request token"); + assert_eq!(scope.path.as_str(), "room/alice", "{version:?}"); + assert_eq!(scope.kind, crate::auth::RequestKind::PublishNamespace, "{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-net/src/session.rs b/rs/moq-net/src/session.rs index 38702fe7d7..a8c306b532 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -367,6 +367,9 @@ impl Session { setup, estimate, } = handles; + if version.is_lite() { + auth.lite(); + } let sample = snapshot(&session); if let Some(estimate) = &estimate && let Ok(mut current) = estimate.write() diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 5f5c87b27e..ea14372a63 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -31,6 +31,11 @@ const LITE_06: &str = "moq-lite-06"; const AUTH_STREAM: u8 = 0x7; /// The first draft that negotiates MoQ Auth, and the newest. const MOQT_17: &str = "moq-transport-17"; +/// A draft-18 session, the shape a standard request-token peer uses. +const MOQT_15: &str = "moq-transport-15"; +const MOQT_16: &str = "moq-transport-16"; +const MOQT_18: &str = "moq-transport-18"; +const MOQT_19: &str = "moq-transport-19"; const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. @@ -196,6 +201,14 @@ async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { } } +/// A request token of kind 0 carrying `value`. +fn request_token(value: &[u8]) -> moq_net::setup::Token { + moq_net::setup::Token { + kind: 0, + value: value.to_vec(), + } +} + #[derive(Default)] struct Options { client_publish: Option, @@ -204,6 +217,14 @@ struct Options { server_subscribe: Option, /// Take the server's AUTH requests before its driver runs. server_requests: bool, + /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. + client_request_token: Option, + /// The client does not offer the MoQ Auth extension (`Extensions::auth` off). + client_decline_auth: bool, + /// The server does not offer the MoQ Solicit extension (`Extensions::solicit` off). + server_decline_solicit: bool, + /// The server does not offer the MoQ Auth extension (`Extensions::auth` off). + server_decline_auth: bool, version: Option<&'static str>, } @@ -228,8 +249,19 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } + if opts.client_decline_auth { + let mut extensions = moq_net::setup::Extensions::default(); + extensions.auth = false; + client = client.with_extensions(extensions); + } let mut server = Server::new().with_versions(version.into()); + if opts.server_decline_solicit || opts.server_decline_auth { + let mut extensions = moq_net::setup::Extensions::default(); + extensions.solicit = !opts.server_decline_solicit; + extensions.auth = !opts.server_decline_auth; + server = server.with_extensions(extensions); + } if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } @@ -239,8 +271,16 @@ async fn connect(opts: Options) -> Pair { let observe = client_transport.clone(); let observe_server = server_transport.clone(); + let client_token = opts.client_request_token; let client_fut = async { let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); + // Set before the driver runs, so the first request already carries it. + if let Some(token) = client_token { + session + .auth() + .set_request_token(token) + .expect("a moq-transport session carries a request token"); + } spawn(driver); session }; @@ -376,6 +416,499 @@ async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { .expect("timed out"); } +/// A request token on a PUBLISH_NAMESPACE authorizes the announce on a session that never +/// negotiated the MoQ Auth extension (draft-14), the standard moq-transport peer shape, when the +/// token reaches the acceptor THROUGH THE DRIVER rather than an inline `verify_request`. +/// +/// This exercises `ietf::start`'s legacy (draft 14-16) branch. That branch built its +/// Subscriber without `.with_auth(auth)`, so the driver's subscriber consulted a fresh +/// default `Handle` instead of the session handle the `requests()` acceptor was installed on: +/// `verify_request` found no `App` acceptor and refused the announce `NOT_SUPPORTED`, and the +/// announce never reached the server origin. The modern (17+) branch already wired +/// `.with_auth`, so only the legacy / no-extension path was affected, and the unit tests +/// missed it by constructing the Subscriber with `.with_auth` by hand. +#[moq_net_sim::test] +async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some("moq-transport-14"), + client_publish: Some(publisher.clone()), + // A request token the acceptor answers unconditionally below. + client_request_token: Some(request_token(b"ok")), + server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), + server_requests: true, + ..Default::default() + }) + .await; + + // The server answers the request token from its acceptor with a grant covering the + // announced path. Held for the test by the returned receiver. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + // The client announces under the token. On the legacy path there is no session grant, + // so the token is the only authorization for the announce. + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + // Mechanism: the token reached the acceptor over the driver (not admitted by a + // permissive default, and not refused NOT_SUPPORTED by a disconnected handle). + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + + // End to end: the verified announce reached the server's subscribe origin. + wait_announced(&relay.consume(), "room/alice", true).await; + }) + .await + .expect("timed out"); +} + +/// A client may decline the MoQ Auth extension, connecting at draft-18 as a peer that +/// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so +/// the server sees `declared.auth == false` and the session carries no connection grant +/// (`None` union). A request-borne `AUTHORIZATION TOKEN` is then the authorizing artifact +/// and reaches the server's acceptor, where a normal draft-18 client's connection grant +/// would cover the request and skip the token. A token-less request on such a +/// session is admitted by the permissive default of the ungranted session. +/// +/// The token is exercised on a SUBSCRIBE, which is always a request and carries the token +/// on every draft. A request token on a PUBLISH_NAMESPACE does NOT reach a moq-net peer at +/// draft-16+ regardless of this option: moq-net declares MoQ Solicit unconditionally, so +/// the announce answers the peer's SUBSCRIBE_NAMESPACE inline via `ietf::Namespace`, which +/// carries no token, and the token-bearing unsolicited PUBLISH_NAMESPACE loop is disabled. +#[moq_net_sim::test] +async fn a_client_may_decline_the_auth_extension() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", timed()).unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + client_request_token: Some(request_token(b"ok")), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // declared.auth == false: the declining client speaks no AUTH, so it holds no + // session grant and cannot present a session token (unlike a normal draft-18 peer). + assert_eq!(pair.client.auth().grant().peek(), None); + assert!(matches!(pair.client.auth().add("x").await, Err(Error::Unsupported))); + + // The client's token-bearing SUBSCRIBE reaches the acceptor: on the `None`-union + // session the covers-gate does not short-circuit, so the token is verified rather + // than admitted by a covering connection grant. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"down".as_ref()).unwrap(); + + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + sub.recv_group().await.unwrap().unwrap(); + + // Token-less: a declining client with no token is admitted by the permissive default. + let bare_publisher = produce_origin(4); + let bare_relay = produce_origin(5); + let bare = connect(Options { + version: Some(MOQT_18), + client_publish: Some(bare_publisher.clone()), + client_decline_auth: true, + server_subscribe: Some(bare_relay.clone()), + ..Default::default() + }) + .await; + let bc = bare_publisher.create_broadcast("room/bob").unwrap(); + bc.announce(Default::default()).unwrap(); + wait_announced(&bare_relay.consume(), "room/bob", true).await; + assert_eq!(bare.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// moq-lite requests carry no token, so a moq-lite session refuses one rather than drop it, +/// and a moq-transport session takes it. +#[moq_net_sim::test] +async fn a_moq_lite_session_refuses_a_request_token() { + within(async { + let lite = connect(Options::default()).await; + let refused = lite.client.auth().set_request_token(request_token(b"ok")); + assert!(matches!(refused, Err(Error::Unsupported)), "{refused:?}"); + + let ietf = connect(Options { + version: Some(MOQT_18), + ..Default::default() + }) + .await; + ietf.client + .auth() + .set_request_token(request_token(b"ok")) + .expect("a moq-transport session takes a request token"); + }) + .await + .expect("timed out"); +} + +/// Draft-15/16 answer an update naming the update's own Request ID, which has no stream of +/// its own on the control stream. The answer must still reach the subscription it renews: +/// the client keeps one renewal in flight, so a second one goes out only once the first is +/// answered there. +#[moq_net_sim::test] +async fn a_legacy_renewal_answer_reaches_its_subscription() { + for version in [MOQT_15, MOQT_16] { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", timed()).unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + client_request_token: Some(request_token(b"aa")), + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&[], &["room/alice"]))); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + let (token, _first) = answered.next().await.expect("the first token reached the acceptor"); + assert_eq!(token, b"aa", "{version}"); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(request_token(b"bb")).unwrap(); + let (token, _second) = answered.next().await.expect("the first renewal reached the acceptor"); + assert_eq!(token, b"bb", "{version}"); + + // Sent only once the first renewal's answer came back to the subscription. + moq_net_sim::sleep(Duration::from_millis(100)).await; + pair.client.auth().set_request_token(request_token(b"cc")).unwrap(); + let (token, _third) = answered.next().await.expect("the second renewal reached the acceptor"); + assert_eq!(token, b"cc", "{version}"); + }) + .await + .unwrap_or_else(|_| panic!("{version}: timed out")); + } +} + +/// A request token renews over the wire: replacing it on the client's `Session::auth()` +/// re-presents it on the live SUBSCRIBE as a REQUEST_UPDATE, the server's driver routes it to +/// the acceptor, and the new grant keeps the subscription alive past the old one's expiry. +/// Runs through both drivers at draft-18, in the shape a base moq-transport peer produces +/// (no MoQ Auth, so the token is what authorizes). +#[moq_net_sim::test] +async fn a_request_token_renews_a_subscription_through_the_driver() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", timed()).unwrap(); + down.announce(Default::default()).unwrap(); + + let first = request_token(b"aa"); + let second = request_token(b"bb"); + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + client_request_token: Some(first.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The first token lapses in a second; the renewal never does. + let expires = Some(now() + Duration::from_secs(1)); + // The acceptor sees the token's value. + let renewal = second.value.clone(); + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, move |token| { + let mut granted = grant(&[], &["room/alice"]); + if token != renewal.as_slice() { + granted.expires = expires; + } + Some(granted) + }); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + let (token, _first_issued) = answered.recv().await.expect("the first token reached the acceptor"); + assert_eq!(token, first.value); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(second.clone()).unwrap(); + let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); + assert_eq!(token, second.value, "the replaced token rides the REQUEST_UPDATE"); + + // Past the first grant's expiry the subscription still delivers. + moq_net_sim::sleep(Duration::from_millis(1500)).await; + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(2000), b"two".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group() + .await + .unwrap() + .expect("the renewed subscription is still live"); + assert_eq!( + pair.client_transport.close_reason(), + None, + "renewal never touches the session" + ); + // The same subscription carried on: a lapse would have ended it, and the client's + // re-subscribe would have reached the acceptor as another request. + assert!( + answered.try_recv().is_err(), + "the original subscription was renewed, not replaced" + ); + }) + .await + .expect("timed out"); +} + +/// The sender honors the receiver's MAX_REQUEST_UPDATES credit: it keeps at most one +/// renewal in flight per request and coalesces replacements that arrive while one is +/// unanswered, so a burst of token changes never outruns the credit (draft-19 section +/// 10.3.1.7). +/// +/// Two drivers over the in-process transport at draft-19, where the serving side advertises +/// and enforces a 16-update credit with a session close ([`SessionError::TooManyRequestUpdates`]). +/// The acceptor holds the first renewal's verifier, the client replaces its token well past +/// the credit, and the test asserts the connection stays up, only the held renewal reaches the +/// acceptor, and resolving it releases exactly the newest token, not any coalesced between. +/// +/// A fire-and-forget sender would put every renewal outstanding behind the held verifier and +/// the serving side would close the session, losing every request on it. +/// +/// [`SessionError::TooManyRequestUpdates`]: moq_net::SessionError::TooManyRequestUpdates +#[moq_net_sim::test] +async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", timed()).unwrap(); + down.announce(Default::default()).unwrap(); + + // A distinct value per credential. + let token = |n: u8| request_token(&[b't', n]); + let initial = token(0); + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_19), + client_subscribe: Some(received.clone()), + client_request_token: Some(initial.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The acceptor runs concurrently with the subscribe (the initial token rides the + // SUBSCRIBE, so nothing reaches it until we subscribe). It reports every token it + // verifies and holds the first renewal's verifier until released, the way a relay with + // a slow authorizer would. + let mut requests = pair.requests.take().expect("server took its requests pre-ok"); + let (seen_tx, mut seen) = futures::channel::mpsc::unbounded::>(); + let (release, release_waiter) = futures::channel::oneshot::channel::<()>(); + let acceptor = moq_net_sim::spawn(async move { + let mut issued = Vec::new(); + let mut count = 0u64; + let mut release_waiter = Some(release_waiter); + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + count += 1; + seen_tx.unbounded_send(token).ok(); + // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, + // held until the test releases it. + if count == 2 + && let Some(release) = release_waiter.take() + { + let _ = release.await; + } + issued.push(request.accept(grant(&[], &["room/alice"]))); + } + }); + + // Establish and deliver one group so the subscription is live. The initial token rides + // the SUBSCRIBE, which the acceptor (above) verifies concurrently. + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group().await.unwrap().unwrap(); + assert_eq!( + seen.next().await.expect("initial token"), + initial.value, + "the SUBSCRIBE carries the token" + ); + + // Replace the token once and let that one renewal reach the held acceptor, so the held + // renewal is deterministic regardless of scheduling. + pair.client.auth().set_request_token(token(1)).unwrap(); + assert_eq!( + seen.next().await.expect("first renewal"), + token(1).value, + "the first replacement goes out immediately" + ); + + // With that renewal held unanswered, replace the token many more times, spaced so the + // driver observes each: the scenario the credit guards. Far past any plausible credit, + // so the test keeps guarding if MAX_REQUEST_UPDATES grows. A fire-and-forget sender + // would put all of these outstanding behind the held verifier and the serving side + // would close the session with TOO_MANY_REQUEST_UPDATES, losing every request on it; + // the one-in-flight rule coalesces them behind the held one instead. + for n in 2..=64u8 { + pair.client.auth().set_request_token(token(n)).unwrap(); + moq_net_sim::sleep(Duration::from_millis(5)).await; + } + + assert_eq!( + pair.client_transport.close_reason(), + None, + "the burst never tripped the receiver's credit" + ); + assert_eq!( + pair.server_transport.close_reason(), + None, + "the server never closed the session" + ); + + // Resolve the held verifier. The sender sends the coalesced renewal carrying the newest + // token, not any of the ones replaced between. + release.send(()).unwrap(); + assert_eq!( + seen.next().await.expect("coalesced renewal"), + token(64).value, + "the newest token wins; the rest are coalesced away" + ); + + // Only the newest coalesced renewal followed the held one: the burst collapsed to one, + // not a backlog queued behind it. + assert!( + moq_net_sim::timeout(Duration::from_millis(100), seen.next()) + .await + .is_err(), + "only the newest coalesced renewal followed, not a backlog" + ); + assert_eq!( + pair.client_transport.close_reason(), + None, + "the session stayed up throughout" + ); + acceptor.abort(); + }) + .await + .expect("timed out"); +} + +/// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited +/// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our +/// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an +/// `AUTHORIZATION TOKEN`; the inline `Namespace` entry has no parameter slot for one. So a +/// request-borne token on an announce reaches the acceptor exactly when the server does not +/// solicit, which is the shape a standard moq-transport peer (an encoder or CDN) always sends. +/// +/// This runs at draft-18, with the client also declining +/// the AUTH extension, so the session's union is `None`, the covers-gate does not short-circuit +/// on a connection grant, and the request token is the authorizing artifact. The +/// control half shows the default: with Solicit declared the client answers inline, no token +/// reaches the acceptor, and the announce is admitted by the permissive default of the +/// ungranted session. +#[moq_net_sim::test] +async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce() { + within(async { + let request_token = request_token(b"ok"); + + // Server declines Solicit: the client sends an unsolicited PUBLISH_NAMESPACE carrying + // the token, which reaches the acceptor as a PublishNamespace request. + let publisher = produce_origin(1); + let relay = produce_origin(2); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + server_decline_solicit: true, + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + // The announce is admitted once the token is granted. + wait_announced(&relay.consume(), "room/alice", true).await; + let (tok, _issued) = answered + .recv() + .await + .expect("the unsolicited PUBLISH_NAMESPACE carried the token to the acceptor"); + assert_eq!(tok, b"ok", "the acceptor saw the request token's decoded value"); + + // Control: with Solicit declared (the default) the same client answers our + // SUBSCRIBE_NAMESPACE inline with a Namespace, which carries no token, so nothing + // reaches the acceptor. The announce is still admitted by the permissive default. + let publisher = produce_origin(3); + let relay = produce_origin(4); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + // server_decline_solicit defaults false: the server declares Solicit. + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/carol").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/carol"], &[]))); + wait_announced(&relay.consume(), "room/carol", true).await; + assert!( + answered.try_recv().is_err(), + "a solicited (inline) announce carries no token, so the acceptor is never consulted" + ); + }) + .await + .expect("timed out"); +} + /// A broadcast published before the grant arrives is checked at admission too. async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { @@ -767,6 +1300,8 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, + client_request_token: None, + ..Default::default() }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { From 9b02df8a8868e697db3534997612bcf1454f2023 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 10:58:03 -0700 Subject: [PATCH 02/53] fix(auth)!: verify a request token against our own authorization, never the peer's grant The request-token admission skipped verification whenever the union, the peer's AUTH_OK grant, covered the request. That grant says what the peer lets us do, not what we let the peer do, so a covering AUTH_OK admitted the peer's requests unverified. Admission now follows this side's limit. A request the limit covers is admitted without the token. Otherwise a token goes to the application, or the request is refused NOT_SUPPORTED without a verifier. A session never limited asks only an application that verifies request tokens. What the peer granted us still bounds what we send. A token-authorized SUBSCRIBE, and a joining FETCH on it, is gated on that grant alone. A token-authorized announce is exempt from the limit while it lasts. The acceptor queue yields auth::Event, with Session(Request) and Scoped(Scoped) variants answered through distinct handles (Issued, Enforced), so a session-token handler must decide what a request token means instead of granting it the session. moq-relay refuses Scoped tokens with NOT_SUPPORTED. The request's slot is taken before the application verifies its token. A token withdrawn while still queued gives it back. One the application took keeps it until answered, and that late answer lands nowhere. The wait for a verdict watches for withdrawal without consuming an update the peer sent meanwhile. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/benches/auth.rs | 12 +- rs/moq-net/src/auth.rs | 606 ++++++++++++++++---------- rs/moq-net/src/ietf/auth.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 458 +++++++++++++------ rs/moq-net/src/ietf/request_stream.rs | 21 + rs/moq-net/src/ietf/subscriber.rs | 531 +++++++++++++++------- rs/moq-net/src/lite/publisher.rs | 2 +- rs/moq-net/src/server.rs | 12 +- rs/moq-net/tests/auth.rs | 79 +++- rs/moq-relay/src/auth/refresh.rs | 11 +- rs/moq-tokio/src/connection/auth.rs | 2 +- 11 files changed, 1198 insertions(+), 538 deletions(-) diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 044c88254a..eea18c05c1 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -43,7 +43,7 @@ async fn pair(version: Version) -> Pair { (session, requests) }; let (client, (server, mut requests)) = futures::join!(client, server); - let initial = requests.next().await.unwrap().accept(auth::Grant::all()); + let initial = session(requests.next().await).accept(auth::Grant::all()); Pair { client, _server: server, @@ -59,7 +59,7 @@ impl Pair { let accept = async { let mut issued = Vec::with_capacity(count); for _ in 0..count { - issued.push(self.requests.next().await.unwrap().accept(auth::Grant::all())); + issued.push(session(self.requests.next().await).accept(auth::Grant::all())); } issued }; @@ -100,3 +100,11 @@ fn admission(c: &mut Criterion) { criterion_group!(benches, admission); criterion_main!(benches); + +/// The next token, which these sessions only ever present for the whole session. +fn session(event: Option) -> auth::Request { + match event { + Some(auth::Event::Session(request)) => request, + _ => panic!("expected a session token"), + } +} diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 03134dcee0..6886e7ad77 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -232,7 +232,12 @@ pub(crate) struct Permit { impl Permit { /// Whether both allow `path`. 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 @@ -438,51 +443,35 @@ impl Handle { Ok(Requests { queue }) } - /// Verify a token that rode on one request, scoped to that request alone. + /// Ask the acceptor to verify a token that rode on one request. /// - /// The token reaches the same acceptor a session token does, tagged with the - /// request's path and kind. The grant the acceptor answers covers only this request, - /// never joins the session union, and ends when the returned [`RequestVerdict`] is - /// dropped, so it lives exactly as long as the request. With no [`requests`] consumer - /// the token cannot be verified, so the verdict is [`Error::Unsupported`] and the - /// caller refuses the request. + /// The token reaches the application as an [`Event::Scoped`]. The grant it answers covers + /// only this request, never joins the session union, and ends when the returned + /// [`RequestVerdict`] is dropped. With no [`requests`](Self::requests) consumer the token + /// cannot be verified, so the verdict is [`Error::Unsupported`]. `hold` is the request's + /// slot, kept while the application verifies the token. pub(crate) fn verify_request( &self, token: crate::setup::Token, path: crate::PathOwned, kind: RequestKind, + hold: Option, ) -> RequestVerdict { - match self.acceptor() { - Some(queue) => { - let issue = kio::Shared::::default(); - // A closed or full queue refuses the token. - queue.push(Request::new_request(token, path, kind, issue.clone())); - RequestVerdict { issue: Some(issue) } - } - // No app verifier took the requests: a request token cannot be checked in band. - None => RequestVerdict { issue: None }, - } - } - - /// Admit a request on the token it carries, scoped to that request alone. - /// - /// The acceptor's grant must cover the request, and is then held for the request's life. - /// The error is what the request is refused with: the acceptor's refusal, UNAUTHORIZED for - /// a grant that does not cover it, or NOT_SUPPORTED with no [`requests`](Self::requests) - /// consumer. - pub(crate) async fn admit_request( - &self, - clock: &crate::time::Clock, - token: crate::setup::Token, - path: crate::PathOwned, - kind: RequestKind, - ) -> Result { - let verdict = self.verify_request(token, path.clone(), kind); - let grant = verdict.grant().await?; - if !kind.covers(&grant, path.as_str()) { - return Err(Error::Unauthorized); - } - Ok(RequestGrant::new(clock, verdict, grant, path, kind)) + let Some(queue) = self.acceptor() else { + return RequestVerdict { issue: None }; + }; + let issue = kio::Shared::new(Issue { + hold, + ..Default::default() + }); + // A closed or full queue refuses the token. + queue.push(Event::Scoped(Scoped { + token, + path, + kind, + issue: Some(issue.clone()), + })); + RequestVerdict { issue: Some(issue) } } /// Decide who answers the peer's tokens: the app if it took the requests, @@ -650,6 +639,30 @@ impl Handle { self.state.read().allows(direction, path) } + /// Whether a request token decides a request at `path`, rather than this side's own + /// authorization of the peer: the limit does not cover it, so the application verifies the + /// token, or the request is refused as unsupported without a verifier. A session never + /// limited asks only an application that verifies request tokens, and admits otherwise. + pub(crate) fn token_decides(&self, direction: Direction, path: &str) -> bool { + let limited = { + let state = self.state.read(); + let (_, limit) = state.parts(direction); + limit.map(|limit| !limit.matches(path)) + }; + match limited { + Some(outside) => outside, + None => self.acceptor().is_some(), + } + } + + /// Whether the peer's grant alone allows `direction` at `path`, allowing everything + /// while it is `None`: what bounds this side whatever authorized the request. + 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 limit alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { @@ -658,18 +671,6 @@ impl Handle { limit.is_none_or(|limit| limit.matches(path)) } - /// Whether the union positively covers `path` right now. Unlike [`allows`](Self::allows), a - /// union that is still `None` (no answer yet, or a version without AUTH) does NOT cover: a - /// request presenting a token is verified by it rather than admitted by the permissive - /// default. Only the token-bearing path uses this; the token-less path keeps `allows`. - pub(crate) fn covers(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - state.union.as_ref().is_some_and(|union| match direction { - Direction::Publish => union.publish.matches(path), - Direction::Subscribe => union.subscribe.matches(path), - }) - } - /// The peer turned out not to negotiate AUTH: fail every token as unsupported and /// close the requests, leaving the union unknown. pub(crate) fn unsupported(&self) { @@ -864,12 +865,12 @@ pub struct Requests { impl Requests { /// Poll for the next token, or `None` once the session ends. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } /// Wait for the next token the peer presents, or `None` once the session ends. - pub async fn next(&mut self) -> Option { + pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await } } @@ -880,6 +881,54 @@ impl Drop for Requests { } } +/// A token the peer presented, from [`Requests::next`]. +/// +/// A session token and a request token answer through different handles, so a handler +/// written for session tokens decides what to do with a request token instead of granting +/// it the session by accident. +#[non_exhaustive] +pub enum Event { + /// A token for the whole session, answered with a grant the peer is told. + Session(Request), + /// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, answered with a grant only that + /// request holds. Experimental. + Scoped(Scoped), +} + +impl Event { + fn ended(&self) -> bool { + let issue = match self { + Self::Session(request) => &request.issue, + Self::Scoped(scoped) => &scoped.issue, + }; + issue.as_ref().expect("unanswered request").read().peer.is_some() + } + + fn reject(self, code: SessionError, reason: &str) { + match self { + Self::Session(request) => request.reject(code, reason), + Self::Scoped(scoped) => scoped.reject(code, reason), + } + } +} + +#[cfg(test)] +impl Event { + pub(crate) fn session(self) -> Request { + match self { + Self::Session(request) => request, + Self::Scoped(_) => panic!("expected a session token"), + } + } + + pub(crate) fn scoped(self) -> Scoped { + match self { + Self::Scoped(scoped) => scoped, + Self::Session(_) => panic!("expected a request token"), + } + } +} + /// Pending application decisions, bounded independently of transport stream credit. #[derive(Clone, Default)] pub(crate) struct Pending { @@ -888,7 +937,7 @@ pub(crate) struct Pending { #[derive(Default)] struct PendingState { - requests: VecDeque, + requests: VecDeque, closed: bool, } @@ -900,7 +949,7 @@ impl Pending { } /// Queue the token for the application, or refuse it when closed or full. - pub(crate) fn push(&self, request: Request) { + pub(crate) fn push(&self, event: Event) { let mut state = self.state.lock(); // Dropping the request refuses it as unanswered. if state.closed { @@ -908,17 +957,17 @@ impl Pending { } // A withdrawn stream must not occupy a slot while the verifier is busy. if state.requests.len() >= 128 { - state.requests.retain(|request| !request.ended()); + state.requests.retain(|event| !event.ended()); } if state.requests.len() >= 128 { drop(state); - request.reject(SessionError::TooManyRequests, "too many pending tokens"); + event.reject(SessionError::TooManyRequests, "too many pending tokens"); return; } - state.requests.push_back(request); + state.requests.push_back(event); } - fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { loop { let mut state = std::task::ready!(self.state.poll(waiter, |state| { if state.closed || !state.requests.is_empty() { @@ -927,10 +976,16 @@ impl Pending { Poll::Pending } })); - while let Some(request) = state.requests.pop_front() { - if !request.ended() { - return Poll::Ready(Some(request)); + while let Some(event) = state.requests.pop_front() { + if event.ended() { + continue; } + // From here the application owns the verification, so a withdrawal no longer + // releases what it holds against the request cap. + if let Event::Scoped(scoped) = &event { + scoped.issue.as_ref().expect("unanswered request").lock().taken = true; + } + return Poll::Ready(Some(event)); } if state.closed { return Poll::Ready(None); @@ -954,8 +1009,16 @@ 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 request token off the queue. + taken: bool, } +/// 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. +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 @@ -987,53 +1050,12 @@ impl Drop for Serving { } } -/// Which request a token rode on. A token on a request authorizes that one request, so -/// the acceptor and the session scope its grant to the request's path and kind rather -/// than to the whole session. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum RequestKind { - /// A SUBSCRIBE: the subscriber 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. - pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { - match self { - Self::Subscribe => grant.subscribe.matches(path), - Self::PublishNamespace => grant.publish.matches(path), - } - } -} - -/// The request a token rode on, which its grant is scoped to. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub struct Scope { - /// The request's path: the track's namespace, or the namespace announced. - pub path: crate::PathOwned, - /// Which request it is. - pub kind: RequestKind, - /// The token's Token Type (moq-transport section 8.9), naming the verifier it is for. - pub token_kind: u64, -} - -/// A token the peer presented, waiting for an answer. -/// -/// A token with no [`scope`](Request::scope) is the connection's own credential (or an -/// AUTH-stream token), granting the whole session. A token that rode on a request is scoped -/// to it: its grant covers only that request and never joins the session union. +/// A token for the whole session, waiting for an answer. /// /// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. pub struct Request { token: Bytes, issue: Option>, - scope: Option, } impl Request { @@ -1041,49 +1063,20 @@ impl Request { Self { token, issue: Some(issue), - scope: None, - } - } - - /// A token that rode on one request, carrying the credential value, its structure - /// type, and the request it belongs to. - pub(crate) fn new_request( - token: crate::setup::Token, - path: crate::PathOwned, - kind: RequestKind, - issue: kio::Shared, - ) -> Self { - Self { - token: Bytes::from(token.value), - issue: Some(issue), - scope: Some(Scope { - path, - kind, - token_kind: token.kind, - }), } } /// The token the peer presented. Empty means the credential its connection - /// already carried, or none. For a request token this is the structure's value. + /// 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. pub async fn closed(&self) -> Error { closed(self.issue.as_ref().expect("unanswered request")).await } - /// The request this token rode on, or `None` for the connection's own credential. - pub fn scope(&self) -> Option<&Scope> { - self.scope.as_ref() - } - /// Grant the token. The grant holds until the returned [`Issued`] is revoked /// or dropped, or the peer withdraws the token. /// @@ -1119,6 +1112,92 @@ fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { } } +/// Which request a token rode on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub 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. + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe => grant.subscribe.matches(path), + Self::PublishNamespace => grant.publish.matches(path), + } + } +} + +/// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, waiting for an answer. Experimental. +/// +/// The session asks only when its own authorization of the peer does not cover the request, +/// and the application verifies the token: carrying one proves nothing. The grant must cover +/// the request, never joins the session's grant, and ends with the request. +/// +/// Dropping it unanswered refuses the request with [`SessionError::Unauthorized`]. +pub struct Scoped { + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + issue: Option>, +} + +impl Scoped { + /// The token the request carried. + pub fn token(&self) -> &crate::setup::Token { + &self.token + } + + /// The request's path: the track's namespace, or the namespace announced. + pub fn path(&self) -> &crate::Path<'static> { + &self.path + } + + /// Which request carried the token. + pub fn kind(&self) -> RequestKind { + self.kind + } + + /// Wait for the request to end before an answer, or the session to close. + pub async fn closed(&self) -> Error { + closed(self.issue.as_ref().expect("unanswered request")).await + } + + /// Admit the request on `grant`, which must cover it. The session enforces the grant on + /// that request alone until the returned [`Enforced`] is revoked or dropped, `expires` + /// passes, or the request ends. + pub fn accept(mut self, grant: Grant) -> Enforced { + let issue = self.issue.take().expect("answered once"); + let mut locked = issue.lock(); + locked.outbox.push_back(Reply::Grant(grant)); + locked.hold = None; + drop(locked); + Enforced { issue } + } + + /// Refuse the request with a session error code and a reason for the peer. + 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; + } +} + +impl Drop for Scoped { + fn drop(&mut self) { + if let Some(issue) = self.issue.take() { + refuse(&issue, SessionError::Unauthorized, "unanswered".to_string()); + issue.lock().hold = None; + } + } +} + /// 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 @@ -1128,16 +1207,16 @@ pub(crate) struct RequestVerdict { } impl RequestVerdict { - /// Wait for the acceptor's first answer: the grant it issued, or a refusal as the - /// [`Error`] whose request code the caller sends the peer. No consumer is - /// [`Error::Unsupported`]; an unanswered (dropped) request is - /// [`SessionError::Unauthorized`]. + /// Wait for the acceptor's first answer. See [`poll_grant`](Self::poll_grant). + #[cfg(test)] pub(crate) async fn grant(&self) -> Result { kio::wait(|waiter| self.poll_grant(waiter)).await } - /// Poll for the acceptor's first answer, so the caller can race the verify against the - /// live grant's deadline and serving rather than blocking on a bare await. + /// Poll for the acceptor's first answer: the grant it issued, or a refusal as the + /// [`Error`] whose request code the caller sends the peer. No consumer is + /// [`Error::Unsupported`]; an unanswered (dropped) request is + /// [`SessionError::Unauthorized`]. pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { let Some(issue) = &self.issue else { return Poll::Ready(Err(Error::Unsupported)); @@ -1158,8 +1237,8 @@ impl RequestVerdict { /// 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 - /// issued grant being dropped. `Pending` while the grant still stands. - pub(crate) fn poll_reply(&self, waiter: &kio::Waiter) -> Poll { + /// 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, @@ -1174,7 +1253,7 @@ impl RequestVerdict { ); Poll::Ready(match guard.outbox.pop_front() { Some(reply) => reply, - // Done with nothing more to read: the acceptor dropped the issued grant, ending + // Done with nothing more to read: the acceptor dropped the enforced grant, ending // the request. None => Reply::Refuse { code: SessionError::Unauthorized, @@ -1187,10 +1266,14 @@ impl RequestVerdict { impl Drop for RequestVerdict { fn drop(&mut self) { if let Some(issue) = &self.issue { - // Tell the acceptor's Issued the request is over, so it stops revalidating this - // request's grant. An unread grant left in the outbox does not matter: the - // request is ending regardless. - issue.lock().peer.get_or_insert(Error::Cancel); + 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; + } } } } @@ -1209,27 +1292,36 @@ pub(crate) struct RequestGrant { /// 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, } impl RequestGrant { - /// Hold `grant` (the acceptor's first answer, already awaited) for the request's life, - /// armed to lapse at its expiry. + /// Hold the acceptor's first answer `res` for the request's life, armed to lapse at its + /// expiry, or the error to refuse the request with: the refusal, or + /// [`Error::Unauthorized`] for a grant that does not cover it. pub(crate) fn new( clock: &crate::time::Clock, verdict: RequestVerdict, - grant: Grant, + res: Result, path: crate::PathOwned, kind: RequestKind, - ) -> Self { + hold: Option, + ) -> Result { + let grant = res?; + if !kind.covers(&grant, path.as_str()) { + return Err(Error::Unauthorized); + } let mut deadline = crate::time::Deadline::new(clock); deadline.set(grant.expires); - Self { + Ok(Self { verdict, grant, deadline, path, kind, - } + hold, + }) } /// Whether `grant` covers the request this guard holds. @@ -1243,6 +1335,11 @@ impl RequestGrant { &self.grant } + /// 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 { + handle.verify_request(token, self.path.clone(), self.kind, self.hold.clone()) + } + /// Apply a renewal's verdict, returning whether it renewed the request. /// /// A grant that still covers the request replaces this one: the new verdict is adopted @@ -1261,11 +1358,10 @@ impl RequestGrant { } /// Resolve with the error that ends the request: the deadline lapsing - /// ([`Error::Unauthorized`]; [`Error::Expired`] once the code split lands), or the - /// acceptor revoking or dropping the grant (its code), or an acceptor-side update (a - /// replacement grant on the same token) 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. + /// ([`Error::Unauthorized`]), the acceptor revoking or dropping the grant (its code), 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(&mut self, waiter: &kio::Waiter) -> Poll { loop { if self.deadline.poll(waiter).is_ready() { @@ -1289,7 +1385,8 @@ impl RequestGrant { } } -/// A grant issued to one of the peer's tokens. Dropping it ends the grant. +/// A grant issued to one of the peer's session tokens, which the peer is told. Dropping it +/// ends the grant. pub struct Issued { issue: kio::Shared, } @@ -1297,10 +1394,7 @@ pub struct Issued { impl Issued { /// Replace the grant, such as with a lowered expiry. pub fn update(&self, grant: Grant) { - let mut issue = self.issue.lock(); - if !issue.done { - issue.outbox.push_back(Reply::Grant(grant)); - } + update(&self.issue, grant); } /// Revoke the grant with a session error code and a reason for the peer. @@ -1315,6 +1409,49 @@ impl Issued { } } +impl Drop for Issued { + fn drop(&mut self) { + self.issue.lock().done = true; + } +} + +/// A grant one request holds on the token it carried, enforced by this side and never told +/// to the peer. Dropping it ends the request. +pub struct Enforced { + issue: kio::Shared, +} + +impl Enforced { + /// Replace the grant, such as with a lowered expiry. One that no longer covers the + /// request ends it. + pub fn update(&self, grant: Grant) { + update(&self.issue, grant); + } + + /// End the request with a session error code and a reason. + pub fn revoke(self, code: SessionError, reason: impl Into) { + refuse(&self.issue, code, reason.into()); + } + + /// Wait for the request to end ([`Error::Cancel`]), or for the session to close. + pub async fn closed(&self) -> Error { + closed(&self.issue).await + } +} + +impl Drop for Enforced { + fn drop(&mut self) { + self.issue.lock().done = true; + } +} + +fn update(issue: &kio::Shared, grant: Grant) { + let mut issue = issue.lock(); + if !issue.done { + issue.outbox.push_back(Reply::Grant(grant)); + } +} + async fn closed(issue: &kio::Shared) -> Error { kio::wait(|waiter| { let issue = ready_or!(issue.poll(waiter, |issue| { @@ -1329,12 +1466,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)] pub(crate) enum Direction { @@ -1355,8 +1486,18 @@ pub(crate) struct Gate { path: crate::PathOwned, direction: Direction, epoch: u64, - /// Watch the limit alone, for a request a token authorized in place of the union. - limit_only: bool, + part: Part, +} + +/// Which part of a [`Permit`] a [`Gate`] watches. +#[derive(Clone, Copy)] +enum Part { + /// The peer's grant and the limit both. + Both, + /// The peer's grant alone: a token the peer presented stands in for the limit. + Granted, + /// The limit alone: a token we presented stands in for the peer's grant. + Limit, } impl Gate { @@ -1366,15 +1507,23 @@ impl Gate { path, direction, epoch: 0, - limit_only: false, + part: Part::Both, + } + } + + /// A gate on the peer's grant alone, for a peer's request its own token authorized. + pub(crate) fn granted(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + part: Part::Granted, + ..Self::new(handle, path, direction) } } - /// A gate on the limit alone: a request token stands in for the union, never for the - /// ceiling this side set on the peer, so a later narrowing still ends the request. + /// A gate on the limit alone, for our request our own token authorized: the peer + /// verifies the token, but a narrowed limit still ends the request. pub(crate) fn limit(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { Self { - limit_only: true, + part: Part::Limit, ..Self::new(handle, path, direction) } } @@ -1385,9 +1534,11 @@ impl Gate { 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)); - let allowed = match self.limit_only { - true => permit.within_limit(self.path.as_str()), - false => permit.matches(self.path.as_str()), + let path = self.path.as_str(); + let allowed = match self.part { + Part::Both => permit.matches(path), + Part::Granted => permit.granted(path), + Part::Limit => permit.within_limit(path), }; if !allowed { return Poll::Ready(()); @@ -1513,10 +1664,10 @@ mod tests { let _requests = handle.requests().unwrap(); let queue = handle.acceptor().unwrap(); for _ in 0..128 { - queue.push(Request::new(Bytes::new(), Default::default())); + queue.push(Event::Session(Request::new(Bytes::new(), Default::default()))); } let overflow = kio::Shared::::default(); - queue.push(Request::new(Bytes::new(), overflow.clone())); + queue.push(Event::Session(Request::new(Bytes::new(), overflow.clone()))); let overflow = overflow.lock(); assert!(overflow.done); assert!(matches!( @@ -1535,14 +1686,14 @@ mod tests { 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())); + queue.push(Event::Session(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())); + queue.push(Event::Session(Request::new(Bytes::from_static(b"live"), Default::default()))); let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; - assert_eq!(request.token().as_ref(), b"live"); + assert_eq!(request.session().token().as_ref(), b"live"); assert!(requests.poll_next(&kio::Waiter::noop()).is_pending()); } @@ -1645,18 +1796,12 @@ mod request_token_tests { async fn a_request_token_reaches_the_acceptor_with_its_context() { let handle = Handle::new(true); let mut requests = handle.requests().expect("take the requests"); - let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe); - - let request = requests.next().await.expect("a request"); - assert_eq!(request.token(), &Bytes::from_static(b"jwt")); - assert_eq!( - request.scope(), - Some(&Scope { - path: subscribe_path(), - kind: RequestKind::Subscribe, - token_kind: 7, - }) - ); + let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe, None); + + let request = requests.next().await.expect("a request").scoped(); + assert_eq!(request.token(), &token(b"jwt", 7)); + assert_eq!(request.path(), &subscribe_path()); + assert_eq!(request.kind(), RequestKind::Subscribe); let _issued = request.accept(Grant::all()); let grant = verdict.grant().await.expect("granted"); @@ -1673,9 +1818,9 @@ mod request_token_tests { let handle = Handle::new(false); let mut requests = handle.requests().expect("take the requests"); for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind); - let request = requests.next().await.expect("a request reaches the acceptor"); - assert_eq!(request.scope().map(|scope| scope.kind), Some(kind)); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind, None); + let request = requests.next().await.expect("a request reaches the acceptor").scoped(); + assert_eq!(request.kind(), kind); let _issued = request.accept(Grant::all()); assert!( verdict.grant().await.expect("granted").publish.matches("room/alice"), @@ -1688,7 +1833,7 @@ mod request_token_tests { #[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), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let err = verdict.grant().await.expect_err("no verifier"); assert!(matches!(err, Error::Unsupported), "{err:?}"); } @@ -1699,8 +1844,8 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); - requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + requests.next().await.unwrap().scoped().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); } @@ -1711,8 +1856,8 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); - let issued = requests.next().await.unwrap().accept(Grant::all()); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); verdict.grant().await.expect("granted"); drop(verdict); let err = issued.closed().await; @@ -1725,22 +1870,14 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); - let _issued = requests.next().await.unwrap().accept(Grant::all()); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + let _issued = requests.next().await.unwrap().scoped().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"); } - /// A session token (the connection credential) carries no request context, so the - /// acceptor can tell it apart from a request token. - #[test] - fn a_session_token_has_no_request_context() { - let request = Request::new(Bytes::new(), kio::Shared::::default()); - assert_eq!(request.scope(), None); - } - use std::time::Duration; fn grant_in(runtime: &crate::time::Clock, secs: u64) -> Grant { @@ -1757,12 +1894,12 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let grant = grant_in(&runtime, 60); let expires = grant.expires; - let _issued = requests.next().await.unwrap().accept(grant); + let _issued = requests.next().await.unwrap().scoped().accept(grant); let answered = verdict.grant().await.unwrap(); - let request_grant = RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + 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()); } @@ -1774,19 +1911,19 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().accept(first); + let _issued = requests.next().await.unwrap().scoped().accept(first); let answered = verdict.grant().await.unwrap(); let mut request_grant = - RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + 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), subscribe_path(), RequestKind::Subscribe); + let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe, None); let second = grant_in(&runtime, 600); let second_expires = second.expires; - let _issued2 = requests.next().await.unwrap().accept(second); + let _issued2 = requests.next().await.unwrap().scoped().accept(second); let renewed = renewal.grant().await; assert!(request_grant.renew(renewal, renewed), "a covering renewal renews"); @@ -1801,12 +1938,12 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, 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 _issued = requests.next().await.unwrap().scoped().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); let mut request_grant = - RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; assert!(matches!(err, Error::Unauthorized), "{err:?}"); @@ -1822,20 +1959,21 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().accept(first); + let _issued = requests.next().await.unwrap().scoped().accept(first); let answered = verdict.grant().await.unwrap(); let mut request_grant = - RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + 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), subscribe_path(), RequestKind::Subscribe); + let renewal = handle.verify_request(token(b"bad", 0), subscribe_path(), RequestKind::Subscribe, None); requests .next() .await .unwrap() + .scoped() .reject(SessionError::Unauthorized, "bad token"); let refused = renewal.grant().await; assert!(matches!(refused, Err(Error::Session(SessionError::Unauthorized)))); @@ -1855,11 +1993,11 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); - let issued = requests.next().await.unwrap().accept(Grant::all()); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); let mut request_grant = - RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + 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()); @@ -1903,12 +2041,12 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); // A grant that never expires, so only the revoke can end the request. - let issued = requests.next().await.unwrap().accept(Grant::all()); + let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); let mut request_grant = - RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); issued.revoke(SessionError::Unauthorized, "revoked"); let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index f24452f7c3..14c2b966ae 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -433,7 +433,7 @@ impl Serve { let mut default = None; match self.handle.acceptor() { Some(requests) => { - requests.push(Request::new(msg.token, issue.clone())); + requests.push(crate::auth::Event::Session(Request::new(msg.token, issue.clone()))); } // Only the connection's own credential has a default answer; a token needs // someone to verify it. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 7a5046d722..73675f8a05 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -433,6 +433,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, }, } @@ -748,66 +751,24 @@ 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. The session grant is checked first; a request it does not cover falls - // back to an AUTHORIZATION TOKEN carried on the SUBSCRIBE itself (MoQ - // request-token), verified by the app's acceptor and scoped to this one request. - // Every admitted subscription is gated so it ends if the session narrows: a - // union-authorized one on the union and the limit, a token-authorized one on the - // limit alone (the union never covered it; its grant ends with the request). - let mut request_grant = None; - // A request presenting a token is authorized by it whenever the session union does - // not positively cover the path. `covers` treats a `None` union (no answer yet, or a - // session without the AUTH extension) as NOT covering, so a standard peer's token is - // verified rather than admitted by the permissive default. A token-less request keeps - // the permissive `allows` default unchanged. - let mut gate = if let Some(token) = &msg.authorization_token - && !self - .auth - .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) - { - // The token stands in for the union, never for the limit this side set on the - // peer: a request outside that ceiling is refused whatever the token grants. - if !self - .auth - .within_limit(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + // origin. Our own authorization of the peer (the limit) admits the request; when it + // does not cover it, an AUTHORIZATION TOKEN on the request goes to the application + // to verify for this request alone (MoQ request-token). 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, path) => { - let err = Error::Unauthorized; - return self.reject_subscribe(stream, request_id, &err, "not granted").await; + Some(token.0.clone()) } - // Held for the subscription's life: the request ends when this grant lapses, is - // revoked, or stops covering it, or when the limit narrows past it (the gate), - // never the session. It authorizes nothing else and never joins the session union. - let admitted = self - .auth - .admit_request( - &self.runtime, - token.0.clone(), - msg.track_namespace.to_owned(), - crate::auth::RequestKind::Subscribe, - ) - .await; - match admitted { - Ok(grant) => request_grant = Some(grant), - Err(err) => return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await, - } - crate::auth::Gate::limit( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - ) - } else if self - .auth - .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) - { - crate::auth::Gate::new( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - ) - } else { + _ => 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; - }; + } // Legal requests we can't honor are refused one at a time, never by closing the // session. A subscription that forwards nothing is only useful to a subscriber // that later turns forwarding on, and serving a Range Filter unfiltered would @@ -822,9 +783,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() @@ -833,6 +795,65 @@ where } }; + 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, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + 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(err) => return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await, + } + } + None => ( + crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ), + None, + ), + }; + let _slot = slot; + // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in // the model, through the tagged `origin::Consumer` the broadcast resolves from. @@ -917,6 +938,7 @@ where }, cache: cache.clone(), namespace: msg.track_namespace.to_owned(), + token: request_grant.is_some(), }, (Filter::NextObject, None) => Joined::Empty, _ => Joined::Unsupported, @@ -955,8 +977,6 @@ where }) .await?; - let mut request_finished = false; - // Serve the track while reading updates; only abrupt closure cancels on draft-19+. // The fill (when one was requested) runs alongside on its own fetch stream; // its failures reset that stream and never touch the subscription. @@ -1101,7 +1121,7 @@ where // 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, &namespace, &request_grant, &mut renewal, update) + .apply_subscription_update(stream, serve, &request_grant, &mut renewal, update) .await { return Some(served); @@ -1241,7 +1261,7 @@ where }, Turn::Update(update) => { if let Err(served) = self - .apply_subscription_update(stream, serve, &namespace, &request_grant, &mut renewal, update) + .apply_subscription_update(stream, serve, &request_grant, &mut renewal, update) .await { return Some(served); @@ -1262,7 +1282,6 @@ where &self, stream: &mut Stream, serve: &mut TrackServe, - namespace: &crate::PathOwned, request_grant: &Option, renewal: &mut request_update::Renewal, update: super::request_stream::Update, @@ -1289,11 +1308,8 @@ where .set_priority(super::priority::from_wire(priority)) .map_err(failed)?; } - if let (Some(token), true) = (update.authorization_token, request_grant.is_some()) { - let verdict = self - .auth - .verify_request(token.0, namespace.clone(), crate::auth::RequestKind::Subscribe); - renewal.verify(verdict, update.request_id); + 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 { @@ -1819,13 +1835,17 @@ where | FetchType::AbsoluteJoining { subscriber_request_id, .. } => { - let (end, cache, namespace) = match self.joined(&mut stream, subscriber_request_id).await? { + let (end, cache, namespace, 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), + }; 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, @@ -2065,7 +2085,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 = { @@ -2102,7 +2122,12 @@ where let refusal = match joined { Err(Error::Timeout) => (Error::Timeout, "subscription not ready"), Err(err) => return Err(err), - Ok(Some(Joined::Group { end, cache, namespace })) => return Ok(Ok((end, cache, namespace))), + Ok(Some(Joined::Group { + end, + cache, + namespace, + token, + })) => return Ok(Ok((end, cache, namespace, token))), Ok(None) => ( match self.version { Version::Draft14 @@ -8149,17 +8174,16 @@ mod serve_tests { 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::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); + // 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 session grant must not cover the request path" + "the limit must not cover the request path" ); (auth, requests, cred) } @@ -8454,10 +8478,10 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s; the renewal never expires. - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); held.push(first.accept(grant_all_expiring(&rt, Some(60)))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap(); + let renewal = requests.next().await.unwrap().scoped(); held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -8550,10 +8574,10 @@ mod serve_tests { let release = release.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); held.push(first.accept(grant_all_expiring(&rt, Some(60)))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap(); + let renewal = requests.next().await.unwrap().scoped(); release.notified().await; if accept { held.push(renewal.accept(grant_all_expiring(&rt, None))); @@ -8748,16 +8772,16 @@ mod serve_tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); 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(); + let a = requests.next().await.unwrap().scoped(); 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(); + let renewal = requests.next().await.unwrap().scoped(); held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } @@ -8815,7 +8839,7 @@ mod serve_tests { // 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 first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8867,7 +8891,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8912,7 +8936,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8957,7 +8981,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9026,9 +9050,9 @@ mod serve_tests { let acceptor = { let release = release.clone(); async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); - let renewal = requests.next().await.unwrap(); + let renewal = requests.next().await.unwrap().scoped(); release.notified().await; renewal.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -9090,7 +9114,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9145,7 +9169,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9185,10 +9209,10 @@ mod serve_tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); let _issued = first.accept(crate::auth::Grant::all()); popped.fetch_add(1, Ordering::Relaxed); - let _never_answered = requests.next().await.expect("a renewal"); + let _never_answered = requests.next().await.expect("a renewal").scoped(); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9250,9 +9274,9 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); let _issued = first.accept(crate::auth::Grant::all()); - let _never_answered = requests.next().await.expect("a renewal"); + let _never_answered = requests.next().await.expect("a renewal").scoped(); std::future::pending::<()>().await }; let mut acceptor = std::pin::pin!(acceptor); @@ -9296,10 +9320,10 @@ mod serve_tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.unwrap(); + let first = requests.next().await.unwrap().scoped(); let _issued = first.accept(grant_all_expiring(&rt, Some(60))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap(); + let renewal = requests.next().await.unwrap().scoped(); renewal.reject(crate::SessionError::Unauthorized, "no"); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -9372,11 +9396,11 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s. - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); 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"); + let _slow = requests.next().await.expect("a renewal").scoped(); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9415,22 +9439,14 @@ mod serve_tests { } /// Publisher / SUBSCRIBE side: on a session without the AUTH extension the union is - /// `None` forever, so `allows` is permissive; a token-bearing SUBSCRIBE must still be - /// verified via `covers`, not admitted by that default. The acceptor is consulted; with the - /// bug the token path was bypassed and the token silently ignored. + /// `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(); - assert!( - auth.allows(crate::auth::Direction::Publish, "room"), - "None union is permissive" - ); - assert!( - !auth.covers(crate::auth::Direction::Publish, "room"), - "None union does not cover" - ); + 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(); @@ -9445,7 +9461,7 @@ mod serve_tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request"); + let request = requests.next().await.expect("a request").scoped(); consulted.fetch_add(1, Ordering::Relaxed); held.push(request.accept(crate::auth::Grant::all())); } @@ -9495,7 +9511,7 @@ mod serve_tests { let serving: Serving = Box::pin(async move { let acceptor = async move { let mut held = Vec::new(); - while let Some(request) = requests.next().await { + while let Some(crate::auth::Event::Scoped(request)) = requests.next().await { held.push(request.accept(grant.clone())); } std::future::pending::>().await @@ -9535,33 +9551,209 @@ mod serve_tests { subscribe: crate::Patterns::new(), expires: None, }; - let (_auth, _h, mut serving) = serve_token_subscribe(read_only, None).await; + 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, None).await; + 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 union, never for the limit this side set on the - /// peer: a SUBSCRIBE outside that ceiling is refused whatever the token grants, and a - /// later narrowing ends one the token admitted. + /// 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_cannot_exceed_the_local_limit() { + 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; + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), nothing).await; assert!( - !still_serving(&mut serving).await, - "a limit of nothing refuses an all-covering token" + still_serving(&mut serving).await, + "a limit of nothing defers to the token" ); - - let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), None).await; + auth.authorize(&crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }); assert!( still_serving(&mut serving).await, - "an unlimited session admits the token" + "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").scoped(); + 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"); + } + + /// A limit that covers the SUBSCRIBE admits it on its own: the token rides along unread. + #[moq_net_sim::test] + async fn a_covering_limit_admits_without_the_verifier() { + let room = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("room").unwrap().into(), + expires: None, + }; + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::default(), Some(room)).await; + assert!(still_serving(&mut serving).await, "the limit admits the subscription"); + drop(auth); + } + + /// With no verifier, a token the limit leaves to decide refuses the request as unsupported + /// rather than unauthorized, 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; + let auth = crate::auth::Handle::new(false); + 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:?}"); + 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"); + + 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; + } + } + + /// 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.scoped()); + } + 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!( - !still_serving(&mut serving).await, - "narrowing the limit ends a token-authorized subscription" + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "the token must not reach the application" ); } diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index df8d2850e8..dcd3ad82f4 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -38,6 +38,27 @@ 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. +pub(super) fn poll_withdrawn( + stream: &mut Stream, + version: Version, + cx: &mut Context<'_>, +) -> Poll<()> { + // Virtual writers on drafts 14-16 are already closed while their control request lives. + if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + && 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 diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5000803f72..4709e27d2d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -557,11 +557,24 @@ 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), } /// What one advertisement said, once its parameters are resolved against the session. @@ -748,22 +761,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); @@ -1054,7 +1055,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 } @@ -1098,7 +1099,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; }; @@ -1109,7 +1110,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); } @@ -1128,7 +1129,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); } } _ => { @@ -1291,42 +1292,73 @@ where return Ok(()); }; - // A request token on the PUBLISH_NAMESPACE authorizes the announce when the session - // grant does not already cover it (MoQ request-token, draft-17 section 9.3.2 / - // draft-18+ section 10.2.2). Purely additive: with no token, or a grant that covers - // the path, everything below is unchanged and the origin model decides scope as it - // did before. - let mut token_grant = None; - if let Some(token) = &msg.authorization_token - && !self.auth.covers(crate::auth::Direction::Subscribe, path.as_str()) - { - // The token's grant must cover this announce; it authorizes nothing else and never - // joins the session union. - let admitted = self - .auth - .admit_request( - &self.runtime, - token.0.clone(), + // Our authorization of the peer (the limit) admits the announce, and holds back one + // it does not cover until it does. When it does not cover it, an AUTHORIZATION TOKEN + // on the announce goes to the application to verify for this announce alone (MoQ + // request-token), 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, path.as_str()) => { + 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 = 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, path.clone(), crate::auth::RequestKind::PublishNamespace, - ) + 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; - match admitted { - Ok(grant) => token_grant = Some(grant), - Err(err) => { - self.write_error(&mut stream, request_id, &err, &err.to_string()) - .await?; - let _ = stream.writer.close().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(err) => { + self.write_error(&mut stream, request_id, &err, &err.to_string()) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } } } - } + }; + let token = token_grant.is_some(); - match self.start_announce(path.clone(), advert) { + match self.start_announce(path.clone(), advert, admit) { 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); + let _ = self.stop_announce(path, token); return Err(err); } } @@ -1350,7 +1382,7 @@ where .await; if attached { - self.stop_announce(path)?; + self.stop_announce(path, token)?; } res @@ -1603,7 +1635,7 @@ where None => { if std::mem::take(attached) { tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone()); + let _ = self.stop_announce(path.clone(), token_grant.is_some()); } Ok(()) } @@ -1612,7 +1644,13 @@ where 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), + false => { + let admit = match token_grant { + Some(_) => Admit::Token(None), + None => Admit::Session, + }; + self.start_announce(path.clone(), advert, admit).map(|()| *attached = true) + } } } }, @@ -1636,12 +1674,9 @@ where // 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 - && token_grant.is_some() + && let Some(grant) = &token_grant { - let verdict = - self.auth - .verify_request(token.0, path.clone(), crate::auth::RequestKind::PublishNamespace); - renewal.verify(verdict, msg.request_id); + renewal.verify(grant.verify_renewal(&self.auth, token.0), msg.request_id); continue; } @@ -1823,18 +1858,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, @@ -1844,7 +1913,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(()) } @@ -1854,7 +1923,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 @@ -1876,20 +1955,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(crate::SessionError::from(err).to_code(), "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, }); @@ -1903,6 +1982,7 @@ where dynamic: Some(dynamic), generation: 0, count: 1, + tokens: usize::from(token), live: Default::default(), _slot: slot, }); @@ -1915,18 +1995,38 @@ 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(crate::SessionError::from(err).to_code(), "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), @@ -1992,7 +2092,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. @@ -6606,10 +6707,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!( @@ -6620,9 +6721,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 @@ -6652,7 +6753,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(); @@ -6712,7 +6813,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. @@ -6752,7 +6853,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 }; @@ -6842,7 +6943,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"); @@ -7039,10 +7140,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", @@ -7231,12 +7332,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(), @@ -7244,7 +7345,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(), @@ -7325,7 +7426,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. @@ -7343,7 +7444,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()); } @@ -7376,7 +7477,7 @@ 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. @@ -7393,7 +7494,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()); } @@ -7414,17 +7515,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()); } @@ -7442,18 +7543,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()); } @@ -7476,7 +7577,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. @@ -7490,7 +7591,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" @@ -7559,7 +7660,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(); @@ -7976,7 +8077,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()); @@ -7984,7 +8085,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"); @@ -8379,9 +8480,15 @@ mod tests { 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.allows(crate::auth::Direction::Subscribe, "room/alice"), - "the session grant must not cover the announced path" + !auth.within_limit(crate::auth::Direction::Subscribe, "room/alice"), + "the limit must not cover the announced path" ); let subscriber = Subscriber::new( @@ -8473,7 +8580,7 @@ mod tests { let acceptor = async { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request"); + let request = requests.next().await.expect("a request").scoped(); held.push(request.accept(crate::auth::Grant::all())); } }; @@ -8507,7 +8614,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async { - let request = requests.next().await.expect("a request"); + let request = requests.next().await.expect("a request").scoped(); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -8535,6 +8642,131 @@ mod tests { ); } + /// 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").scoped(); + 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"); + } + + /// A limit that covers the announce admits it on its own: the token rides along unread. + #[moq_net_sim::test] + async fn a_covering_limit_admits_without_the_verifier() { + const VERSION: Version = Version::Draft18; + let limit = crate::auth::Grant { + publish: crate::Pattern::subtree("room").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION, Some(limit)); + 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 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, "the limit admits the announce"); + assert!( + requests.poll_next(&kio::Waiter::noop()).is_pending(), + "the token never reached the verifier" + ); + } + + /// 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").scoped(); + 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 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. @@ -8552,10 +8784,10 @@ mod tests { 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"); + let first = requests.next().await.expect("a request").scoped(); 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"); + let renewal = requests.next().await.expect("a renewal").scoped(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await @@ -8625,10 +8857,10 @@ mod tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); 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"); + let renewal = requests.next().await.expect("a renewal").scoped(); renewal.reject(crate::SessionError::Unauthorized, "no"); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await @@ -8696,9 +8928,9 @@ mod tests { let rt = rt.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); - let renewal = requests.next().await.expect("a renewal"); + let renewal = requests.next().await.expect("a renewal").scoped(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); std::future::pending::<()>().await } @@ -8780,15 +9012,15 @@ mod tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); 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"); + let a = requests.next().await.expect("a renewal").scoped(); 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"); + let renewal = requests.next().await.expect("a renewal").scoped(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } @@ -8842,7 +9074,7 @@ mod tests { // 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 first = requests.next().await.expect("a request").scoped(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8899,7 +9131,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8942,7 +9174,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -8984,7 +9216,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9016,10 +9248,27 @@ mod tests { } /// Like [`auth_announce_harness`] but the session never negotiated the MoQ Auth extension, - /// so its union is `None` forever: `allows` is permissive, `covers` is not. No credential - /// is presented; the acceptor answers request tokens regardless. + /// 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, @@ -9034,16 +9283,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); let requests = auth.requests().unwrap(); - assert!( - auth.allows(crate::auth::Direction::Subscribe, "room/alice"), - "a None union is permissive for allows" - ); - assert!( - !auth.covers(crate::auth::Direction::Subscribe, "room/alice"), - "a None union does not cover" - ); let subscriber = Subscriber::new( crate::time::Clock::sim(), @@ -9063,10 +9303,9 @@ mod tests { (subscriber, requests, consumer, session) } - /// On a session without the AUTH extension the union is `None` forever, so `allows` is - /// permissive; a token-bearing PUBLISH_NAMESPACE must still be verified (`covers`), not - /// admitted by that default. Admitted on a covering grant; refused UNAUTHORIZED (not - /// admitted) on refusal. This is the standard moq-transport peer shape the quest targets. + /// 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; @@ -9074,7 +9313,7 @@ mod tests { // 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); + 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 = { @@ -9082,7 +9321,7 @@ mod tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request reaches the acceptor"); + let request = requests.next().await.expect("a request reaches the acceptor").scoped(); consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); held.push(request.accept(crate::auth::Grant::all())); } @@ -9120,10 +9359,10 @@ mod tests { // Refused: not admitted by the permissive default. { - let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + 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 request = requests.next().await.expect("a request reaches the acceptor").scoped(); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -9166,10 +9405,10 @@ mod tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request"); + let first = requests.next().await.expect("a request").scoped(); 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"); + let _never_answered = requests.next().await.expect("a renewal").scoped(); popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await } @@ -9220,10 +9459,10 @@ mod tests { 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); + 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 request = requests.next().await.expect("a request reaches the acceptor").scoped(); let _issued = request.accept(grant); std::future::pending::<()>().await }; @@ -9260,7 +9499,7 @@ mod tests { #[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); + 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(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0d8feb0d3a..7503b2dade 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -462,7 +462,7 @@ impl AuthServe { let issue = serving.issue.clone(); match self.shared.auth.acceptor() { Some(requests) => { - requests.push(crate::auth::Request::new(msg.token, issue.clone())); + requests.push(crate::auth::Event::Session(crate::auth::Request::new(msg.token, issue.clone()))); } // Only the connection's own credential has a default answer; a token // needs someone to verify it. Tell the presenter explicitly. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 04953f580c..ffcd4abb7d 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1260,11 +1260,15 @@ mod tests { }, crate::PathOwned::from("room/alice".to_string()), crate::auth::RequestKind::PublishNamespace, + None, ); - let received = requests.next().await.expect("the pre-ok consumer receives the request"); - let scope = received.scope().expect("a request token"); - assert_eq!(scope.path.as_str(), "room/alice", "{version:?}"); - assert_eq!(scope.kind, crate::auth::RequestKind::PublishNamespace, "{version:?}"); + let received = requests + .next() + .await + .expect("the pre-ok consumer receives the request") + .scoped(); + assert_eq!(received.path().as_str(), "room/alice", "{version:?}"); + assert_eq!(received.kind(), crate::auth::RequestKind::PublishNamespace, "{version:?}"); let _issued = received.accept(crate::auth::Grant::all()); assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index ea14372a63..18b117a53b 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -311,22 +311,66 @@ async fn connect(opts: Options) -> Pair { } } +/// A grant [`serve`] issued: to a session token, or to a request token. +enum Answered { + Session(auth::Issued), + Request(auth::Enforced), +} + +impl Answered { + fn update(&self, grant: Grant) { + match self { + Self::Session(issued) => issued.update(grant), + Self::Request(enforced) => enforced.update(grant), + } + } + + fn revoke(self, code: SessionError, reason: &str) { + match self { + Self::Session(issued) => issued.revoke(code, reason), + Self::Request(enforced) => enforced.revoke(code, reason), + } + } + + async fn closed(&self) -> Error { + match self { + Self::Session(issued) => issued.closed().await, + Self::Request(enforced) => enforced.closed().await, + } + } +} + /// Answer the peer's tokens from a table, holding every grant (and any request the /// table leaves unanswered) for as long as the returned task lives. fn serve( mut requests: auth::Requests, answer: impl Fn(&[u8]) -> Option + 'static, -) -> futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)> { +) -> futures::channel::mpsc::UnboundedReceiver<(Vec, Answered)> { let (tx, rx) = futures::channel::mpsc::unbounded(); moq_net_sim::spawn(async move { let mut unanswered = Vec::new(); - while let Some(request) = requests.next().await { - let token = request.token().to_vec(); - match answer(&token) { - Some(grant) => { - let _ = tx.unbounded_send((token, request.accept(grant))); + while let Some(event) = requests.next().await { + let (token, answered) = match event { + auth::Event::Session(request) => { + let token = request.token().to_vec(); + let answered = answer(&token).map(|grant| Answered::Session(request.accept(grant))); + (token, answered) + } + auth::Event::Scoped(request) => { + let token = request.token().value.clone(); + let answered = match answer(&token) { + Some(grant) => Some(Answered::Request(request.accept(grant))), + None => { + unanswered.push(request); + None + } + }; + (token, answered) } - None => unanswered.push(request), + _ => unreachable!("an unknown token kind"), + }; + if let Some(answered) = answered { + let _ = tx.unbounded_send((token, answered)); } } }); @@ -744,8 +788,8 @@ async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { let mut issued = Vec::new(); let mut count = 0u64; let mut release_waiter = Some(release_waiter); - while let Some(request) = requests.next().await { - let token = request.token().to_vec(); + while let Some(auth::Event::Scoped(request)) = requests.next().await { + let token = request.token().value.clone(); count += 1; seen_tx.unbounded_send(token).ok(); // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, @@ -1096,7 +1140,7 @@ async fn a_refused_token_reports_the_code(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); moq_net_sim::spawn(async move { - while let Some(request) = requests.next().await { + while let Some(auth::Event::Session(request)) = requests.next().await { match request.token().is_empty() { true => { std::mem::forget(request.accept(Grant::default())); @@ -1131,7 +1175,7 @@ async fn a_refused_setup_token_grants_nothing(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); moq_net_sim::spawn(async move { - while let Some(request) = requests.next().await { + while let Some(auth::Event::Session(request)) = requests.next().await { request.reject(SessionError::Unauthorized, "bad credential"); } }); @@ -1154,7 +1198,9 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); // Wait for the setup token to be queued, then hand it back to the queue's owner. - let first = requests.next().await.expect("setup token"); + let Some(auth::Event::Session(first)) = requests.next().await else { + panic!("setup token") + }; drop(first.accept(Grant::default())); let auth = pair.client.auth(); let pending = moq_net_sim::spawn(async move { auth.add("queued").await.map(drop) }); @@ -1201,7 +1247,10 @@ async fn an_issued_grant_ends_with_its_session(version: &'static str) { }) .await; let mut requests = pair.requests.take().unwrap(); - let issued = requests.next().await.expect("setup token").accept(Grant::default()); + let Some(auth::Event::Session(setup)) = requests.next().await else { + panic!("setup token") + }; + let issued = setup.accept(Grant::default()); granted(&pair.client).await; pair.server_driver.abort(); @@ -1629,7 +1678,9 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { // Hold the answer, so the peer's discovery request is in long before the grant. let mut requests = pair.requests.take().unwrap(); - let setup = requests.next().await.expect("setup token"); + let Some(auth::Event::Session(setup)) = requests.next().await else { + panic!("setup token") + }; let leaked = moq_net_sim::timeout( Duration::from_millis(100), wait_announced(&relay.consume(), "foo/bar", true), diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 0c50dde4a9..2132792bf7 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -230,7 +230,7 @@ impl Refresh { pub(super) async fn ended(&mut self) -> lease::Reason { loop { enum Event { - Request(Option), + Request(Option), Verified(Option, Option), Settled, Changed(u64, Change), @@ -250,7 +250,14 @@ impl Refresh { } }; match event { - Event::Request(Some(request)) => self.request(request), + Event::Request(Some(auth::Event::Session(request))) => self.request(request), + // A request token would authorize one request beyond every credential, which + // the relay's grants do not model. + Event::Request(Some(auth::Event::Scoped(scoped))) => { + scoped.reject(SessionError::Unsupported, "request tokens are not verified") + } + // Dropping a token of a kind added later refuses it. + Event::Request(Some(_)) => {} Event::Request(None) => self.requests_open = false, Event::Settled => {} Event::Verified(verdict, settling) => { diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index b447952da6..64ee1b9289 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -325,7 +325,7 @@ mod tests { let acceptor = tokio::spawn(async move { let _session = session; let mut initial = Vec::new(); - while let Some(request) = requests.next().await { + while let Some(auth::Event::Session(request)) = requests.next().await { if request.token().is_empty() { initial.push(request.accept(auth::Grant::default())); } else if sender.send(request).is_err() { From 537c773acd26ae126f213c627f31c030559000a3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 11:07:34 -0700 Subject: [PATCH 03/53] fix(net): renew a draft-16 announce's request token in band Draft-16 renews a PUBLISH_NAMESPACE with a REQUEST_UPDATE that names it on the control stream, but the receiver dropped those updates and the sender never sent them, so a draft-16 token-authorized announce could only lapse. PublishNamespaceUpdate gains the draft-16 shape, carrying the PUBLISH_NAMESPACE's Request ID. The receiver renews the token and answers by the update's own Request ID. A refused or malformed renewal, and a lapsed or revoked grant, cancel the advertisement with PUBLISH_NAMESPACE_CANCEL, since its request outlives its stream there. The sender re-presents a replaced token on live draft-16 announces. js/net decodes the draft-16 update instead of closing the session, answers it by its Request ID, and refuses repeated tokens the same way. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/cluster.test.ts | 19 +- js/net/src/ietf/publish_namespace.ts | 32 ++- js/net/src/ietf/subscriber.test.ts | 44 ++- js/net/src/ietf/subscriber.ts | 25 +- rs/moq-net/src/ietf/publish_namespace.rs | 60 ++-- rs/moq-net/src/ietf/publisher.rs | 14 +- rs/moq-net/src/ietf/subscriber.rs | 332 ++++++++++++++--------- rs/moq-net/tests/auth.rs | 41 +++ 8 files changed, 403 insertions(+), 164 deletions(-) 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 1bddc4f45f..b9e84c8a7f 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 05dda0d959..c8238ea969 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, @@ -1070,24 +1071,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. @@ -1122,7 +1137,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/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 050d9564f7..885e4c4789 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -97,16 +97,19 @@ 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. @@ -121,6 +124,7 @@ 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, @@ -141,7 +145,11 @@ 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) @@ -157,14 +165,15 @@ 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), }; decode_params!(r, version, 0x03 => authorization_token: Vec, @@ -174,6 +183,7 @@ impl Message for PublishNamespaceUpdate { super::token::unique(&authorization_token)?; Ok(Self { request_id, + namespace_request_id, hops, cost, authorization_token: authorization_token.into_iter().next(), @@ -595,21 +605,33 @@ 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), - authorization_token: None, + 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 73675f8a05..ae7ad7c1ff 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2780,8 +2780,8 @@ where /// REQUEST_UPDATE (MoQ request-token renewal), so a refreshed credential reaches the peer /// before the old grant lapses. /// - /// Draft-17+ only: earlier drafts have no PUBLISH_NAMESPACE_UPDATE, so a token set on them - /// rides the initial advertisement and is not renewed in place. Clearing the token (`None`) + /// Draft-16+ only: earlier drafts have no update for an advertisement, so a token set on + /// them rides the initial advertisement and is not renewed in place. Clearing the token (`None`) /// is not a renewal and sends nothing. A refusal withdraws the announce, since the receiver /// closes its stream on a renewal it cannot grant; the retry re-offers it carrying the new /// token, honoring any retry interval the refusal named. @@ -2793,7 +2793,7 @@ where let Some(token) = token else { return Ok(()); }; - if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + if matches!(self.version, Version::Draft14 | Version::Draft15) { return Ok(()); } @@ -2803,8 +2803,13 @@ where let Some(request) = ns.requests.get_mut(&suffix) else { continue; }; + // Draft-16 names the advertisement in the update, and its answer names the update, + // which the control stream routes back here while this is held. + let legacy = self.version == Version::Draft16; + let _follow_up = legacy.then(|| self.control.follow_up(request_id, request.request_id)); let update = ietf::PublishNamespaceUpdate { request_id, + namespace_request_id: legacy.then_some(request.request_id), hops: None, cost: None, authorization_token: Some(crate::ietf::token::RequestToken(token.clone())), @@ -11075,6 +11080,7 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, @@ -11228,6 +11234,7 @@ 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(), )), @@ -11369,6 +11376,7 @@ mod tests { VERSION, &ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4709e27d2d..ee1ec2952d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1378,7 +1378,15 @@ where // 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, token_grant) + .run_publish_namespace_updates( + &mut stream, + request_id, + &path, + msg.cluster, + peer, + &mut attached, + token_grant, + ) .await; if attached { @@ -1414,6 +1422,7 @@ where async fn run_publish_namespace_updates( &mut self, stream: &mut Stream, + request_id: RequestId, path: &PathOwned, mut held: Option, peer: cluster::Peer, @@ -1530,7 +1539,11 @@ where return stream.writer.closed().await; } Turn::Failed(err) => return Err(err), - Turn::Ended(err) => return Err(err), + Turn::Ended(err) => { + self.cancel_announce(stream, request_id, path, &err, "request token grant ended") + .await?; + return Err(err); + } Turn::Renewal(request_update::Verdict { verdict, grant: res, @@ -1548,18 +1561,15 @@ where // the session stays up and the old grant does not survive. self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") .await?; - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; - } + self.cancel_announce(stream, request_id, path, &Error::Unauthorized, "renewal not granted") + .await?; return Ok(()); } }; - // 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) - { + // 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); @@ -1581,9 +1591,12 @@ where return Ok(()); } - // 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) { + // 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. + let update_id = ietf::RequestId::decode(&mut body.decoder(self.version), self.version)?; + if self.version != Version::Draft16 + && let Err(err) = self.control.accept(update_id) + { self.session .clone() .close(crate::SessionError::from(&err).to_code(), "invalid request ID"); @@ -1594,11 +1607,10 @@ where // refused update does. Err(DecodeError::MalformedAuthToken) => { let err = Error::Decode(DecodeError::MalformedAuthToken); - self.write_error(stream, request_id, &err, "repeated AUTHORIZATION TOKEN") + self.write_error(stream, update_id, &err, "repeated AUTHORIZATION TOKEN") + .await?; + self.cancel_announce(stream, request_id, path, &err, "repeated AUTHORIZATION TOKEN") .await?; - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; - } return Ok(()); } msg => msg?, @@ -1685,6 +1697,33 @@ where } } + /// 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?; + } + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + Ok(()) + } + /// Reject an incoming PUBLISH. /// /// PUBLISH offers a single track, so honoring it means routing per @@ -7609,6 +7648,7 @@ 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, @@ -7741,6 +7781,7 @@ mod tests { let err = subscriber .run_publish_namespace_updates( &mut stream, + RequestId(1), &crate::Path::new("room/host"), Some(clean), peer, @@ -7772,6 +7813,7 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(clean.clone()), peer, @@ -7819,6 +7861,7 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(clean.clone()), peer, @@ -7868,6 +7911,7 @@ mod tests { writer .encode(&ietf::PublishNamespaceUpdate { request_id: RequestId(3), + namespace_request_id: None, hops: None, cost: Some(0), authorization_token: None, @@ -7888,6 +7932,7 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(held.clone()), peer, @@ -7937,6 +7982,7 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(clean.clone()), peer, @@ -7984,6 +8030,7 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(clean.clone()), peer, @@ -8044,6 +8091,7 @@ mod tests { let mut attached = true; let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, + RequestId(1), &path, Some(clean.clone()), peer, @@ -8544,6 +8592,7 @@ mod tests { 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()), @@ -8560,6 +8609,7 @@ mod tests { 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()), @@ -8772,134 +8822,161 @@ mod tests { /// re-arms the deadline. #[moq_net_sim::test] async fn a_publish_namespace_renewal_extends_past_the_old_expiry() { - const VERSION: Version = 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(); + 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").scoped(); - 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").scoped(); - 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, - )); + 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").scoped(); + 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").scoped(); + 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" + 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" ); - if answered.load(std::sync::atomic::Ordering::Relaxed) >= 2 { - break; + // 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; } - 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()); + // 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!( - futures::poll!(run.as_mut()).is_pending(), - "renewal did not extend the announce" + routed_now(&consumer, "room/alice").is_some(), + "the renewed announce must still be attached" ); - settle().await; } - assert!( - routed_now(&consumer, "room/alice").is_some(), - "the renewed announce must still be attached" - ); } + /// 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, not by waiting for the old - /// 60s grant to lapse. The session stays up. + /// 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() { - const VERSION: Version = 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(); + 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").scoped(); - 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").scoped(); - 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, - )); + 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").scoped(); + 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").scoped(); + 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; + // 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: "renewal not granted".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" + ); } - 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() - ); } + /// 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 @@ -8966,6 +9043,7 @@ mod tests { 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()), diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 18b117a53b..e0845ebde6 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -508,6 +508,47 @@ async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { .expect("timed out"); } +/// Draft-16 renews an announce's token with a REQUEST_UPDATE naming the PUBLISH_NAMESPACE on +/// the control stream: replacing the client's token reaches the server's acceptor as a renewal, +/// whose answer finds its way back without disturbing the announce. +#[moq_net_sim::test] +async fn a_request_token_renews_a_legacy_announce_through_the_driver() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some(MOQT_16), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token(b"aa")), + server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), + server_requests: true, + // Unsolicited, so the announce is a PUBLISH_NAMESPACE carrying the token. + server_decline_solicit: true, + ..Default::default() + }) + .await; + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &[]))); + + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + let (token, _first) = answered.recv().await.expect("the token reached the acceptor"); + assert_eq!(token, b"aa"); + wait_announced(&relay.consume(), "room/alice", true).await; + + pair.client.auth().set_request_token(request_token(b"bb")).unwrap(); + let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); + assert_eq!(token, b"bb", "the replaced token rides the REQUEST_UPDATE"); + + moq_net_sim::sleep(Duration::from_millis(100)).await; + wait_announced(&relay.consume(), "room/alice", true).await; + assert_eq!(pair.client_transport.close_reason(), None, "renewal never touches the session"); + assert_eq!(pair.server_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A client may decline the MoQ Auth extension, connecting at draft-18 as a peer that /// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so /// the server sees `declared.auth == false` and the session carries no connection grant From d5b388b2c2e75fe44e1bcad778cef51b84e4009e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 11:42:45 -0700 Subject: [PATCH 04/53] feat(tokio): a connection presents its request token across reconnects connect::Config::with_request_token seeds the token, and Connection::auth().set_request_token replaces it. The connection owns one cell that each session is attached to before its driver runs, so the first request carries the newest token, and a replacement renews the live session's requests in band. A moq-lite session cannot carry one, so the dial fails rather than drop it. A joining FETCH on a token-authorized subscription now ends with that subscription, so expiry, revocation, or a refused renewal also stops it. Docs and the finished quest are updated. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 1 + quest/m1/auth/README.md | 2 - quest/m1/auth/request-gate.md | 6 +- quest/m1/auth/request-token.md | 121 ----------- quest/m3/auth-request-lease.md | 14 +- rs/moq-net/src/auth.rs | 89 ++++++-- rs/moq-net/src/ietf/publish_namespace.rs | 7 +- rs/moq-net/src/ietf/publisher.rs | 196 +++++++++++++++-- rs/moq-net/src/ietf/subscriber.rs | 36 ++- rs/moq-net/src/lite/publisher.rs | 5 +- rs/moq-net/src/server.rs | 6 +- rs/moq-net/tests/auth.rs | 6 +- rs/moq-tokio/src/client.rs | 55 +++-- rs/moq-tokio/src/connect.rs | 41 ++++ rs/moq-tokio/src/connection.rs | 182 +++++++++++++++- rs/moq-tokio/src/connection/auth.rs | 265 +++++++++++++++++++++++ 16 files changed, 823 insertions(+), 209 deletions(-) delete mode 100644 quest/m1/auth/request-token.md diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 9a4a6fde0f..8c5c6a4077 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -30,6 +30,7 @@ subset drops. What a peer actually observes against this implementation: - **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. +- **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 5c311789ab..12954e3036 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -111,8 +111,6 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required - [WebSocket refusal](/quest/m1/auth/ws-unauthorized.md) - the relay serves WebSocket through moq-tokio, so a refused token closes the session as Unauthorized, as QUIC does -- [Request tokens](/quest/m1/auth/request-token.md) - an experimental Rust - verifier hook confines a token's authority to its request and renews it - [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is refused up front and held to the grant through a gate its dispatcher owns - [Expired token error](/quest/m1/auth/expired-error.md) - an expired token diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md index 64791085c1..f97792f193 100644 --- a/quest/m1/auth/request-gate.md +++ b/quest/m1/auth/request-gate.md @@ -22,7 +22,7 @@ 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 +- **Effective authorization.** Dispatch uses independently verified local session permission first, then the admitted request grant; peer-issued AUTH_OK grants cannot authorize incoming requests. Preserve @@ -48,10 +48,6 @@ 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 - ## Related - [JS fetch grant watch](https://github.com/moq-dev/moq/pull/5260) - the outgoing diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md deleted file mode 100644 index 7ad6fa89b4..0000000000 --- a/quest/m1/auth/request-token.md +++ /dev/null @@ -1,121 +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.** Keep one event queue with explicit session/request - variants and distinct acceptance handles. A session-token consumer must - consciously handle or refuse the request variant; an optional `scope()` - on a shared `accept()` is insufficient. The types must distinguish a - session grant advertised to a peer from a locally enforced request grant. - Update existing consumers together, including the relay's explicit refusal - of request verification. This intentionally changes the acceptor API so - existing session handlers cannot silently acquire new security semantics. -- **Admission.** Independently verified local session permission takes - precedence. Only when it does not cover the request is the request token - verified, with the accepted grant checked against that request's path and - kind. 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: a breaking Rust acceptor event/type distinction, plus the -request-token setter and persistent connection integration. Export only the -types consumers need; do not keep the optional-scope compatibility shape. -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/m3/auth-request-lease.md b/quest/m3/auth-request-lease.md index da72b0a5f4..0407c53bdb 100644 --- a/quest/m3/auth-request-lease.md +++ b/quest/m3/auth-request-lease.md @@ -5,9 +5,8 @@ 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. Requests independently covered by locally verified session permission +refuses request-token verification as `Unsupported`, though it consumes +session-token events. Requests independently covered by locally verified session permission do not require request-token verification. ## Plan @@ -19,8 +18,8 @@ the lease integration below is future work, not a capability of the hook. An application acceptor built on moq-net answers request tokens through the explicit request variant of `requests()` itself and does not need this; -it is the relay's half of -[Request tokens](/quest/m1/auth/request-token.md). +it is the relay's half of the request-token hook from +[#5148](https://github.com/moq-dev/moq/pull/5148). - A per-request call on `moq_auth::Client`, not the `Client::attach` that session tokens use. `attach` connects @@ -39,8 +38,3 @@ it is the relay's half of session grant and sees its lease end with the request. 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/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 6886e7ad77..92c6050cd9 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -415,6 +415,10 @@ impl Handle { /// Answer every token the peer presents, instead of the default acceptor. /// + /// Session tokens arrive as [`Event::Session`]. An `AUTHORIZATION TOKEN` on a SUBSCRIBE + /// or PUBLISH_NAMESPACE that [`authorize`](Self::authorize) does not cover arrives as + /// [`Event::Scoped`] and decides that request alone. Experimental. + /// /// 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 @@ -583,6 +587,9 @@ impl Handle { /// 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 request outside it that carries its own token is decided by that token through + /// [`requests`](Self::requests), or refused as unsupported when nothing answers them. + /// /// A narrower grant 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 @@ -1686,10 +1693,16 @@ mod tests { let queue = handle.acceptor().unwrap(); let expired = kio::Shared::::default(); for _ in 0..128 { - queue.push(Event::Session(Request::new(Bytes::from_static(b"withdrawn"), expired.clone()))); + queue.push(Event::Session(Request::new( + Bytes::from_static(b"withdrawn"), + expired.clone(), + ))); } expired.lock().peer = Some(Error::Cancel); - queue.push(Event::Session(Request::new(Bytes::from_static(b"live"), Default::default()))); + queue.push(Event::Session(Request::new( + Bytes::from_static(b"live"), + Default::default(), + ))); let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; @@ -1845,7 +1858,12 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - requests.next().await.unwrap().scoped().reject(SessionError::Unauthorized, "no"); + requests + .next() + .await + .unwrap() + .scoped() + .reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); } @@ -1899,7 +1917,15 @@ mod request_token_tests { let expires = grant.expires; let _issued = requests.next().await.unwrap().scoped().accept(grant); let answered = verdict.grant().await.unwrap(); - let request_grant = RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).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()); } @@ -1916,8 +1942,15 @@ mod request_token_tests { let first_expires = first.expires; let _issued = requests.next().await.unwrap().scoped().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = - RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); + let mut 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), subscribe_path(), RequestKind::Subscribe, None); @@ -1942,8 +1975,15 @@ mod request_token_tests { // Held for the request's life, so only the deadline (not a drop) ends it. let _issued = requests.next().await.unwrap().scoped().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); - let mut request_grant = - RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); + let mut 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; assert!(matches!(err, Error::Unauthorized), "{err:?}"); @@ -1964,8 +2004,15 @@ mod request_token_tests { let first_expires = first.expires; let _issued = requests.next().await.unwrap().scoped().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = - RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); + let mut 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), subscribe_path(), RequestKind::Subscribe, None); @@ -1996,8 +2043,15 @@ mod request_token_tests { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); - let mut request_grant = - RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); + let mut 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()); @@ -2045,8 +2099,15 @@ mod request_token_tests { // A grant that never expires, so only the revoke can end the request. let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); - let mut request_grant = - RequestGrant::new(&runtime, verdict, Ok(answered), subscribe_path(), RequestKind::Subscribe, None).unwrap(); + let mut request_grant = RequestGrant::new( + &runtime, + verdict, + Ok(answered), + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); issued.revoke(SessionError::Unauthorized, "revoked"); let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 885e4c4789..1c93b8a6be 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -148,7 +148,9 @@ impl Message for PublishNamespaceUpdate { 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)?; + self.namespace_request_id + .ok_or(EncodeError::Version)? + .encode(w, version)?; } Version::Draft17 => { self.request_id.encode(w, version)?; @@ -622,7 +624,8 @@ mod tests { 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(), + msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version) + .is_err(), "{version:?}" ); } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ae7ad7c1ff..b79d99e37d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1790,6 +1790,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, mut gate) = match fetch_type { FetchType::Standalone { namespace, @@ -1846,6 +1851,7 @@ where 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, @@ -1896,6 +1902,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. @@ -1907,7 +1924,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) @@ -2057,7 +2074,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()) @@ -4882,9 +4899,14 @@ mod serve_tests { None, ); let mut finished = false; - let run = h - .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None, crate::Path::new("room").to_owned()); + let run = h.publisher.run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ); assert!(futures::poll!(std::pin::pin!(run)).is_ready(), "{version}"); } } @@ -5002,10 +5024,14 @@ 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 {}, None, crate::Path::new("room").to_owned()) - ); + let mut run = std::pin::pin!(h.publisher.run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned() + )); // An unfinished group keeps its stream open. let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -5561,9 +5587,19 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None, crate::Path::new("room").to_owned()) + .run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ) .await; - assert!(matches!(served, Some(Served::Track(Err(_)) | Served::UpdateFailed(_))), "{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}"); } @@ -5630,9 +5666,19 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None, crate::Path::new("room").to_owned()) + .run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ) .await; - assert!(matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), "{version}: {served:?}"); + assert!( + matches!(served, 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(); @@ -7778,6 +7824,100 @@ mod serve_tests { } } + /// 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_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").scoped() }; + 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); + } + } + /// 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] @@ -9644,11 +9784,16 @@ mod serve_tests { 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; + let res = h + .publisher + .clone() + .run_subscribe_stream(stream, token_subscribe()) + .await; assert!(res.is_ok(), "refused, not failed: {res:?}"); let refusal = { let log = Log::default(); - let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + 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 { @@ -9671,7 +9816,10 @@ mod serve_tests { 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"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "an unlimited session serves it" + ); settle().await; } } @@ -9695,7 +9843,10 @@ mod serve_tests { 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_err(), + "the pending token holds the slot" + ); } assert!( h.publisher.subscriptions.acquire().is_ok(), @@ -9732,7 +9883,10 @@ mod serve_tests { "answering releases the slot" ); let ended = late.closed().await; - assert!(matches!(ended, Error::Cancel), "the late answer lands nowhere: {ended:?}"); + 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 @@ -9754,7 +9908,11 @@ mod serve_tests { 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; + 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(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index ee1ec2952d..8c20854c9e 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1297,7 +1297,11 @@ where // on the announce goes to the application to verify for this announce alone (MoQ // request-token), 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, path.as_str()) => { + Some(token) + if self + .auth + .token_decides(crate::auth::Direction::Subscribe, path.as_str()) => + { Some(token.0.clone()) } _ => None, @@ -1419,6 +1423,7 @@ where /// only what changed. Each one is answered with REQUEST_OK, or REQUEST_ERROR and a /// closed stream when it cannot be applied, which withdraws the advertisement /// (moq-transport Section 9.5.1). + #[allow(clippy::too_many_arguments)] async fn run_publish_namespace_updates( &mut self, stream: &mut Stream, @@ -1661,7 +1666,8 @@ where Some(_) => Admit::Token(None), None => Admit::Session, }; - self.start_announce(path.clone(), advert, admit).map(|()| *attached = true) + self.start_announce(path.clone(), advert, admit) + .map(|()| *attached = true) } } } @@ -7516,7 +7522,9 @@ mod tests { &peer, ) .expect("route"); - subscriber.start_announce(path.clone(), advertised, Admit::Session).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. @@ -8730,7 +8738,11 @@ mod tests { } settle().await; } - assert_eq!(consulted.load(std::sync::atomic::Ordering::Relaxed), 1, "the verifier ran"); + 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"); } @@ -8892,7 +8904,6 @@ mod tests { } } - /// 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. @@ -8961,7 +8972,11 @@ mod tests { .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), + error_code: request::to_code( + &Error::Unauthorized, + request::Kind::PublishNamespace, + version, + ), reason_phrase: "renewal not granted".into(), }) .await @@ -9391,7 +9406,8 @@ mod tests { // 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 (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 = { @@ -9437,7 +9453,8 @@ mod tests { // 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 (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").scoped(); @@ -9537,7 +9554,8 @@ mod tests { 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 (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").scoped(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 7503b2dade..8230569783 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -462,7 +462,10 @@ impl AuthServe { let issue = serving.issue.clone(); match self.shared.auth.acceptor() { Some(requests) => { - requests.push(crate::auth::Event::Session(crate::auth::Request::new(msg.token, issue.clone()))); + requests.push(crate::auth::Event::Session(crate::auth::Request::new( + msg.token, + issue.clone(), + ))); } // Only the connection's own credential has a default answer; a token // needs someone to verify it. Tell the presenter explicitly. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index ffcd4abb7d..9dd12b6463 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1268,7 +1268,11 @@ mod tests { .expect("the pre-ok consumer receives the request") .scoped(); assert_eq!(received.path().as_str(), "room/alice", "{version:?}"); - assert_eq!(received.kind(), crate::auth::RequestKind::PublishNamespace, "{version:?}"); + assert_eq!( + received.kind(), + crate::auth::RequestKind::PublishNamespace, + "{version:?}" + ); let _issued = received.accept(crate::auth::Grant::all()); assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index e0845ebde6..30185235d2 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -542,7 +542,11 @@ async fn a_request_token_renews_a_legacy_announce_through_the_driver() { moq_net_sim::sleep(Duration::from_millis(100)).await; wait_announced(&relay.consume(), "room/alice", true).await; - assert_eq!(pair.client_transport.close_reason(), None, "renewal never touches the session"); + assert_eq!( + pair.client_transport.close_reason(), + None, + "renewal never touches the session" + ); assert_eq!(pair.server_transport.close_reason(), None); }) .await diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 4bb2d0ab33..a34a1cde02 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -4,7 +4,7 @@ //! QUIC settings ([`crate::quic::Config`]) a binary shares with its accept half. The //! accept side is [`crate::server`]. -use crate::connection::Goaway; +use crate::connection::{Goaway, RequestToken}; use crate::{Addrs, Backoff, Connection, Error}; use futures::future::BoxFuture; #[cfg(all(feature = "websocket", feature = "noq"))] @@ -44,6 +44,9 @@ impl Config { #[derive(Clone)] pub struct Client { moq: moq_net::Client, + /// The request token from [`crate::connect::Config::with_request_token`], which seeds each + /// [`Connection`]'s own. + pub(crate) request_token: Option, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list @@ -146,6 +149,7 @@ impl Client { moq: moq_net::Client::new() .with_versions(versions.clone()) .with_extensions(config.extensions), + request_token: config.request_token.clone().map(|token| token.0), #[cfg(any( feature = "noq", feature = "iroh", @@ -323,7 +327,7 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, _addr: crate::connect::Addr, _token: &RequestToken) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable noq, iroh, websocket, tcp, or uds feature", )) @@ -343,10 +347,10 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. - let attempt = Box::pin(self.connect_inner(addr)); + let attempt = Box::pin(self.connect_inner(addr, token)); // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever @@ -392,7 +396,7 @@ impl Client { feature = "tcp", feature = "uds" ))] - async fn connect_inner(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_inner(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { let url = addr.url().clone(); // Transports with no request URI of their own advertise the request target in the // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where @@ -411,7 +415,7 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -426,7 +430,7 @@ impl Client { self.resolution_delay, ) .await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -435,7 +439,7 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Unix)); } @@ -443,7 +447,7 @@ impl Client { // nothing to race and the fallback's answer is the connect's verdict. #[cfg(feature = "websocket")] if matches!(url.scheme(), "ws" | "wss") { - return self.connect_websocket(addr).await; + return self.connect_websocket(addr, token).await; } // iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw @@ -462,7 +466,7 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Iroh)); } @@ -482,19 +486,19 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(&moq, addr, quic_handle).await; + return self.race_moq_connect(&moq, addr, quic_handle, token).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - let session = connect_session(&moq, session).await?; + let session = connect_session(&moq, session, token).await?; return Ok(Dialed::new(session, quic_transport(&url))); } } #[cfg(feature = "websocket")] - return self.connect_websocket(addr).await; + return self.connect_websocket(addr, token).await; #[cfg(not(feature = "websocket"))] return Err(Error::NoBackend("no QUIC backend matched; this should not happen")); @@ -503,11 +507,11 @@ impl Client { /// Connect over WebSocket alone. qmux over WebSocket carries the path in its request /// URI, so the plain builder is used: repeating it in the SETUP is a protocol violation. #[cfg(feature = "websocket")] - async fn connect_websocket(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_websocket(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - let session = connect_session(&self.moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&self.moq, crate::transport::Session::new(session), token).await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) } @@ -529,6 +533,7 @@ impl Client { moq: &moq_net::Client, addr: crate::connect::Addr, quic: Q, + token: &RequestToken, ) -> crate::Result where Q: Future> + Unpin + Send + 'static, @@ -547,9 +552,9 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), + TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic, token).await?, transport)), TransportRace::WebSocket { session, quic } => { - let session = match connect_session(&self.moq, crate::transport::Session::new(session)).await { + let session = match connect_session(&self.moq, crate::transport::Session::new(session), token).await { Ok(session) => session, Err(err) => { // The fallback got through but its MoQ handshake did not. A QUIC dial still @@ -569,7 +574,7 @@ impl Client { // UDP gets through after all, so the next dial gives QUIC its head start. crate::websocket::forget(&url); // Both handshakes failing is still a two-arm loss: a mixed auth pair stays retryable. - let session = connect_session(moq, quic) + let session = connect_session(moq, quic, token) .await .map_err(|quic| race_error(quic.into(), err))?; return Ok(Dialed::new(session, transport)); @@ -578,9 +583,10 @@ impl Client { let mut dialed = Dialed::new(session, crate::Transport::WebSocket); dialed.upgrade = quic.ok().map(|quic| { let moq = moq.clone(); + let token = token.clone(); let dial = Box::pin(async move { let quic = quic.await?; - Ok(Box::pin(async move { Ok(connect_session(&moq, quic).await?) }) as Handshake) + Ok(Box::pin(async move { Ok(connect_session(&moq, quic, &token).await?) }) as Handshake) }); Upgrade::new(dial, transport) }); @@ -904,10 +910,14 @@ pub(crate) fn race_error(quic: Error, websocket: Error) -> Error { async fn connect_session( client: &moq_net::Client, transport: S, + token: &RequestToken, ) -> Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; + // Before the driver runs, so the first request carries the newest token. A session that + // cannot carry one (moq-lite) fails the dial rather than drop it. + token.attach(session.auth())?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) @@ -1628,7 +1638,7 @@ mod tests { let dialed = tokio::time::timeout( std::time::Duration::from_secs(10), - client.race_moq_connect(&client.moq, ws_addr, quic), + client.race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()), ) .await .expect("client connect timed out") @@ -1718,7 +1728,7 @@ mod tests { Ok::<_, crate::Error>(crate::transport::Session::new(session)) }); let cc = client - .race_moq_connect(&client.moq, ws_addr, quic) + .race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()) .await .expect("client connect failed"); @@ -1886,7 +1896,8 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let mut attempt = Box::pin(client.dial(url.into())); + let token = RequestToken::default(); + let mut attempt = Box::pin(client.dial(url.into(), &token)); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 9689ddb654..fa46a3fb23 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,6 +361,20 @@ impl ConnectError { mod tests { use super::*; + /// A request token seeded on the dial config is kept out of its `Debug` and never + /// serialized, since a config is routinely logged and written back. + #[test] + fn a_request_token_stays_out_of_debug_and_serde() { + let config = Config::default().with_request_token(moq_net::setup::Token { + kind: 0, + value: b"s3cr3t".to_vec(), + }); + assert!(config.request_token.is_some()); + let debug = format!("{config:?}"); + assert!(!debug.contains("s3cr3t") && debug.contains("<6 bytes>"), "{debug}"); + assert!(!toml::to_string(&config).expect("serialize").contains("request_token")); + } + /// The dial config offers every extension unless told otherwise, and leaves the /// default out when serialized. #[test] @@ -479,6 +493,16 @@ failover_delay = "1s" } } +/// A request token held by a [`Config`], shown by type and length only so it stays out of logs. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RequestToken(pub moq_net::setup::Token); + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "kind={} <{} bytes>", self.0.kind, self.0.value.len()) + } +} + /// Whether `extensions` offers every extension, the default a config leaves out. pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { *extensions == moq_net::setup::Extensions::default() @@ -634,6 +658,12 @@ pub struct Config { #[usage(skip)] pub extensions: moq_net::setup::Extensions, + /// The `AUTHORIZATION TOKEN` every session presents on its own requests; see + /// [`with_request_token`](Self::with_request_token). + #[serde(skip)] + #[usage(skip)] + pub(crate) request_token: Option, + /// TLS trust and client-certificate settings (`--connect-tls-*`). #[usage(flatten)] #[serde(default)] @@ -713,6 +743,7 @@ impl Default for Config { timeout_arg: None, version: Vec::new(), extensions: Default::default(), + request_token: None, tls: Default::default(), once: None, reconnect: None, @@ -727,6 +758,16 @@ impl Default for Config { } impl Config { + /// Present `token` as the `AUTHORIZATION TOKEN` on each session's SUBSCRIBE and + /// PUBLISH_NAMESPACE requests. Experimental: the peer's application must verify it. + /// + /// It seeds the connection's request token, which [`crate::Auth::set_request_token`] + /// replaces across reconnects. + pub fn with_request_token(mut self, token: moq_net::setup::Token) -> Self { + self.request_token = Some(RequestToken(token)); + self + } + /// Every released spelling this config was parsed from, across this section and /// the TLS and WebSocket ones it owns, each paired with what replaced it. /// diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 1d25f25a2e..9a65ec0732 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2,6 +2,7 @@ pub mod auth; pub use auth::Auth; +pub(crate) use auth::RequestToken; use std::task::{Poll, ready}; use std::time::Duration; @@ -488,6 +489,8 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, + /// Attached to each session as its handshake completes, detached once nothing is live. + request_token: RequestToken, } impl Shared { @@ -545,6 +548,11 @@ impl Shared { /// serving throughout, so only the status moves back. fn stayed(&self) { self.status(Status::Connected); + // The failed upgrade attached its own session during its handshake, so renewals go + // back to the session that kept serving, which carried the token when it attached. + if let Some(session) = &self.state.read().session { + let _ = self.request_token.attach(session.auth()); + } } fn status(&self, status: Status) { @@ -554,7 +562,7 @@ impl Shared { } /// Nothing is live. Clears the session so a handle can't be handed a closed one, - /// and drops the estimates that belonged to it. + /// and drops the estimates and the request-token attachment that belonged to it. fn clear_session(&self) { if let Ok(mut state) = self.state.write() { // Count one close per live session; a second call without an @@ -570,6 +578,7 @@ impl Shared { } let _ = self.send_bw.set(None); let _ = self.recv_bw.set(None); + self.request_token.detach(); } /// No live session or pending upgrade remains. @@ -691,6 +700,8 @@ pub struct Connection { /// The last status returned by [`status`](Self::status), for change detection. /// Per-clone: a clone starts from its parent's cursor and diverges from there. last_reported: Option, + /// The request token presented across reconnects, which the loop seeds each session from. + request_token: RequestToken, } /// The connection loop, shared by every [`Connection`] clone and aborted when the @@ -726,6 +737,9 @@ impl Connection { let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); + let request_token = RequestToken::new(client.request_token.clone()); + let loop_token = request_token.clone(); + let task = tokio::spawn(async move { let reconnect = client.reconnect; let shared = Shared { @@ -733,6 +747,7 @@ impl Connection { send_bw, recv_bw, closed: task_closed, + request_token: loop_token, }; // Isolated inside `shared`, so a panic still records a terminal error // instead of closing the channels as if the caller had stopped it. @@ -760,6 +775,7 @@ impl Connection { send_bandwidth, recv_bandwidth, last_reported: None, + request_token, } } @@ -1120,7 +1136,7 @@ impl Connection { let url = addr.url(); tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone())); + let mut dial = std::pin::pin!(client.dial(addr.clone(), &shared.request_token)); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -1306,6 +1322,7 @@ impl Connection { Auth { connection: self.state.clone(), pacing: self.pacing, + request: self.request_token.clone(), } } @@ -2223,12 +2240,173 @@ mod tests { config.init(Default::default()).expect("build client") } + /// A request token of kind 0 carrying `value`. + #[cfg(feature = "tcp")] + fn tok(value: &[u8]) -> moq_net::setup::Token { + moq_net::setup::Token { + kind: 0, + value: value.to_vec(), + } + } + + /// The connection owns the request token across the reconnect loop: the dial config seeds + /// the cell the loop presents on each (re)connected session, and renewing through + /// [`Connection::auth`] updates that same cell, so a renewal survives into the next session. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn the_connection_owns_the_request_token_across_the_loop() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + let client = config + .with_request_token(tok(b"token-one")) + .init(Default::default()) + .expect("client"); + + // A dead address: the loop never connects, but the handle and its cell exist at once. + let conn = client.connect(dead().await); + assert_eq!( + format!("{:?}", conn.request_token), + "kind=0 <9 bytes>", + "seeded by the config" + ); + + conn.auth().set_request_token(tok(b"token-three")).unwrap(); + assert_eq!( + format!("{:?}", conn.request_token), + "kind=0 <11 bytes>", + "renewing through auth() updates the seed the next session reads" + ); + } + + /// A draft-16 server for the first-request tests: it accepts one session, reports on + /// `paused` once the client's SETUP has arrived (the client's handshake then waits on this + /// side's reply), holds its SERVER_SETUP until `release` fires, and sends the first + /// request-borne token it verifies on the returned receiver. + /// + /// It declines Solicit, so each announce rides its own PUBLISH_NAMESPACE, the request that + /// carries a token inline. + #[cfg(feature = "tcp")] + async fn first_request_token( + paused: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, + ) -> (Url, tokio::sync::oneshot::Receiver>) { + let mut cfg = crate::listen::Config::default(); + cfg.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + cfg.version = vec!["moq-transport-16".parse().unwrap()]; + cfg.extensions.solicit = false; + let mut listener = cfg + .init(Default::default()) + .expect("server") + .listen() + .await + .expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); + let url = url(&format!("tcp://{addr}/")); + let (first_tx, first_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let request = listener.accept().await.expect("the client dials"); + let _ = paused.send(()); + let _ = release.await; + + let request = request.with_subscriber(crate::origin::spawn()); + let mut requests = request.auth().requests().expect("requests are taken before ok"); + let _session = request.ok().await.expect("accept"); + let mut first_tx = Some(first_tx); + let mut sessions = Vec::new(); + let mut scoped = Vec::new(); + while let Some(event) = requests.next().await { + match event { + moq_net::auth::Event::Session(req) => sessions.push(req.accept(moq_net::auth::Grant::all())), + moq_net::auth::Event::Scoped(req) => { + if let Some(tx) = first_tx.take() { + let _ = tx.send(req.token().value.clone()); + } + scoped.push(req.accept(moq_net::auth::Grant::all())); + } + _ => {} + } + } + }); + (url, first_rx) + } + + /// A draft-16 client presenting `token`, with a broadcast announced before it connects so + /// its first PUBLISH_NAMESPACE is pending when the session's driver starts. It declines the + /// AUTH extension, so the request token is the only authorizing artifact. + #[cfg(feature = "tcp")] + fn announcing_client(token: moq_net::setup::Token, url: Url) -> (Connection, moq_net::broadcast::Producer) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let origin = crate::origin::spawn(); + let broadcast = origin.create_broadcast("room/alice").expect("broadcast"); + broadcast.announce(Default::default()).expect("announce"); + + let mut cfg = crate::connect::Config::default(); + cfg.tls.insecure = Some(true); + cfg.version = vec!["moq-transport-16".parse().unwrap()]; + cfg.extensions.auth = false; + let client = cfg + .with_request_token(token) + .init(Default::default()) + .expect("client") + .with_publisher(origin.consume()) + .with_reconnect(false); + (client.connect(url), broadcast) + } + + /// The dial config's token reaches the FIRST request on a connected session: the session + /// is attached to the connection's token before its driver starts, and nothing re-sets it + /// afterwards, so a session started without it would present nothing. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn the_first_request_on_a_connected_session_carries_the_token() { + let (paused_tx, _paused) = tokio::sync::oneshot::channel(); + let (release, release_rx) = tokio::sync::oneshot::channel(); + let (url, first) = first_request_token(paused_tx, release_rx).await; + release.send(()).unwrap(); + + let (_conn, _broadcast) = announcing_client(tok(b"a"), url); + let first = tokio::time::timeout(Duration::from_secs(10), first) + .await + .expect("the first request never reached the server") + .expect("server ended"); + assert_eq!(first, b"a", "the first request carries the configured token"); + } + + /// A token set while the handshake is still pending is the one the first request carries: + /// the session is seeded when it is created, not from a snapshot taken before dialing. The + /// server holds its SERVER_SETUP until the renewal returns, and draft-16 carries the token + /// inline on the first PUBLISH_NAMESPACE. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_renewal_during_the_handshake_reaches_the_first_request() { + let (paused_tx, paused) = tokio::sync::oneshot::channel(); + let (release, release_rx) = tokio::sync::oneshot::channel(); + let (url, first) = first_request_token(paused_tx, release_rx).await; + + let (conn, _broadcast) = announcing_client(tok(b"a"), url); + paused.await.expect("the client's SETUP reached the server"); + conn.auth().set_request_token(tok(b"b")).unwrap(); + release.send(()).unwrap(); + + let first = tokio::time::timeout(Duration::from_secs(10), first) + .await + .expect("the first request never reached the server") + .expect("server ended"); + assert_eq!( + first, b"b", + "the first request carries the renewal, not the dial-time token" + ); + } + fn shared() -> Shared { Shared { state: kio::Producer::default(), send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), + request_token: RequestToken::default(), } } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 64ee1b9289..33c13bcef3 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -14,9 +14,21 @@ use crate::{Error, abort::AbortOnDrop}; pub struct Auth { pub(super) connection: kio::Consumer, pub(super) pacing: Pacing, + pub(super) request: RequestToken, } impl Auth { + /// Present `token` as the `AUTHORIZATION TOKEN` on this connection's SUBSCRIBE and + /// PUBLISH_NAMESPACE requests, now and after reconnects. Experimental: the peer's + /// application must verify it, and success means only that the token was set. + /// + /// Replacing it renews the live session's token-authorized requests in band. Errors + /// with [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose + /// requests carry no token; later sessions are still seeded with it. + pub fn set_request_token(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + self.request.set(token) + } + /// Watch the current session's union, clearing it while disconnected. pub fn grant(&self) -> Watch { Watch { @@ -50,6 +62,84 @@ impl Auth { } } +/// The request token a connection presents and the session it presents it on, shared +/// across its sessions and shown by length only so it stays out of logs. +/// +/// Seeding a new session ([`attach`](Self::attach)) and replacing the token +/// ([`set`](Self::set)) each read and write under one lock, so neither can leave a session +/// on an older token than the one set last, and a token set while a session is still +/// handshaking is the one it starts with. The lock is held only across synchronous work. +#[derive(Clone, Default)] +pub(crate) struct RequestToken(std::sync::Arc>); + +#[derive(Default)] +struct Presenting { + token: Option, + /// The newest session, which a renewal reaches; `None` while nothing is live. + session: Option>, +} + +/// Where a request token is presented: a session's auth handle. +pub(crate) trait Present: Send { + /// Present `token` on this session's requests from now on, or refuse it when the session + /// cannot carry one (moq-lite). + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error>; +} + +impl Present for auth::Handle { + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + self.set_request_token(token) + } +} + +impl RequestToken { + pub(crate) fn new(token: Option) -> Self { + Self(std::sync::Arc::new(std::sync::Mutex::new(Presenting { + token, + session: None, + }))) + } + + /// Replace the token, on the newest session too, and seed every later session with it. + /// + /// Errors when the newest session cannot carry it; later sessions are still seeded. + fn set(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + let mut presenting = self.0.lock().unwrap(); + presenting.token = Some(token.clone()); + match &presenting.session { + Some(session) => session.present(token), + None => Ok(()), + } + } + + /// Seed `session` with the current token and make it the session later renewals reach. + /// + /// Errors when the session cannot carry the token set, which leaves it unattached. + pub(crate) fn attach(&self, session: impl Present + 'static) -> Result<(), moq_net::Error> { + let mut presenting = self.0.lock().unwrap(); + if let Some(token) = &presenting.token { + session.present(token.clone())?; + } + presenting.session = Some(Box::new(session)); + Ok(()) + } + + /// Stop reaching the session once nothing is live; renewals then only update the seed. + pub(crate) fn detach(&self) { + // Also runs from `Shared`'s drop, so take a poisoned lock rather than panic while unwinding. + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).session = None; + } +} + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0.lock().unwrap().token { + Some(token) => write!(f, "kind={} <{} bytes>", token.kind, token.value.len()), + None => write!(f, ""), + } + } +} + /// A presented token; dropping it withdraws it from live and draining sessions. pub struct Token { _task: AbortOnDrop, @@ -279,6 +369,180 @@ async fn present( } } +#[cfg(test)] +mod request_token_tests { + use super::*; + + impl RequestToken { + fn get(&self) -> Option { + self.0.lock().unwrap().token.clone() + } + } + + /// A request token of kind 0 carrying `value`. + fn tok(value: &[u8]) -> moq_net::setup::Token { + moq_net::setup::Token { + kind: 0, + value: value.to_vec(), + } + } + + /// The request token is shown by length only, never printed. + #[test] + fn request_token_debug_is_redacted() { + let token = RequestToken::new(Some(tok(b"s3cr3t"))); + assert_eq!(format!("{token:?}"), "kind=0 <6 bytes>"); + assert_eq!(format!("{:?}", RequestToken::default()), ""); + } + + /// A session double that records every token it is presented and parks inside its first + /// [`Present::present`] until the test releases it: exactly where a seed or a renewal + /// writes the session, after reading the token it writes. + struct Parked { + presented: std::sync::Arc>>, + /// Taken by the first present, which reports that it parked and waits to be released. + park: std::sync::Mutex, std::sync::Arc)>>, + } + + impl Parked { + fn new(presented: &std::sync::Arc>>) -> Self { + Self { + presented: presented.clone(), + park: Default::default(), + } + } + } + + impl Present for Parked { + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + let park = self.park.lock().unwrap().take(); + if let Some((parked, release)) = park { + parked.send(()).unwrap(); + release.wait(); + } + self.presented.lock().unwrap().push(token); + Ok(()) + } + } + + /// Park the first present `write` makes on another thread, then race `renew` against it, + /// returning every token the session was presented in order. + /// + /// While `write` is parked mid-present, the cell's lock must still be held, so `renew` + /// cannot land between `write`'s read and its write and be overwritten by the older token. + fn race_renewal( + token: &RequestToken, + write: impl FnOnce(&RequestToken, Parked) + Send + 'static, + renew: moq_net::setup::Token, + ) -> Vec { + let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let release = std::sync::Arc::new(std::sync::Barrier::new(2)); + let (step, steps) = std::sync::mpsc::channel(); + let parked = Parked::new(&presented); + *parked.park.lock().unwrap() = Some((step, release.clone())); + + let writer = { + let token = token.clone(); + std::thread::spawn(move || write(&token, parked)) + }; + steps.recv().expect("the write under test never presented a token"); + assert!( + token.0.try_lock().is_err(), + "a renewal could land between the parked write's read and its write" + ); + + let renewer = { + let token = token.clone(); + std::thread::spawn(move || token.set(renew).unwrap()) + }; + release.wait(); + writer.join().expect("writer panicked"); + renewer.join().expect("renewer panicked"); + std::mem::take(&mut *presented.lock().unwrap()) + } + + /// A session that cannot carry a request token (moq-lite). + struct Refuses; + + impl Present for Refuses { + fn present(&self, _token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + Err(moq_net::Error::Unsupported) + } + } + + /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial + /// does too, and a renewal reaching it fails while still seeding later sessions. + #[test] + fn a_session_that_cannot_carry_the_token_refuses_it() { + let token = RequestToken::new(Some(tok(b"token-a"))); + assert!(matches!(token.attach(Refuses), Err(moq_net::Error::Unsupported))); + + let token = RequestToken::new(None); + token.attach(Refuses).expect("nothing to present"); + assert!(matches!(token.set(tok(b"token-b")), Err(moq_net::Error::Unsupported))); + assert_eq!(token.get(), Some(tok(b"token-b")), "later sessions are still seeded"); + } + + /// A reconnect seeding its new session cannot replay the token it read over a renewal that + /// lands meanwhile: the session ends on the renewal, as the reconnect seed does. + #[test] + fn a_renewal_racing_the_reconnect_seed_is_not_replayed() { + let a = tok(b"token-a"); + let b = tok(b"token-b"); + let token = RequestToken::new(Some(a.clone())); + + let presented = race_renewal(&token, |token, session| token.attach(session).unwrap(), b.clone()); + + assert_eq!(presented, [a, b.clone()], "seeded with A, then renewed to B"); + assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); + } + + /// Two renewals present in the order they land, so the session ends on the one that landed + /// last, the same token the next reconnect is seeded with. + #[test] + fn concurrent_renewals_leave_the_session_on_the_last() { + let a = tok(b"token-a"); + let b = tok(b"token-b"); + let token = RequestToken::new(None); + + let presented = race_renewal( + &token, + { + let a = a.clone(); + move |token, session| { + // Nothing to seed, so the attach presents nothing and the renewal parks. + token.attach(session).unwrap(); + token.set(a).unwrap(); + } + }, + b.clone(), + ); + + assert_eq!(presented, [a, b.clone()], "renewed to A, then to B"); + assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); + } + + /// Once nothing is live a renewal only updates the seed, and the next attach picks it up. + #[test] + fn a_renewal_between_sessions_seeds_the_next() { + let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let session = || Parked::new(&presented); + let token = RequestToken::new(Some(tok(b"token-a"))); + + token.attach(session()).unwrap(); + token.detach(); + token.set(tok(b"token-b")).unwrap(); + assert_eq!( + presented.lock().unwrap().len(), + 1, + "a renewal reached a detached session" + ); + + token.attach(session()).unwrap(); + assert_eq!(presented.lock().unwrap().last().cloned(), Some(tok(b"token-b"))); + } +} + #[cfg(all(test, feature = "tcp"))] mod tests { use super::*; @@ -417,6 +681,7 @@ mod tests { Auth { connection: connection.consume(), pacing: Pacing::new(&super::super::Backoff::default()), + request: RequestToken::default(), } } From 810ea41ab0b538a89f674ed91fa38ef8bed79938 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 13:50:18 -0700 Subject: [PATCH 05/53] refactor(auth)!: name a request token's event Event::Request requests() now yields Event::{Session, Request}, naming each variant by what the token rode on. Scoped and Enforced keep their names. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 46 ++++++++++++------------- rs/moq-net/src/ietf/publisher.rs | 56 +++++++++++++++---------------- rs/moq-net/src/ietf/subscriber.rs | 44 ++++++++++++------------ rs/moq-net/src/server.rs | 2 +- rs/moq-net/tests/auth.rs | 4 +-- rs/moq-relay/src/auth/refresh.rs | 2 +- rs/moq-tokio/src/connection.rs | 2 +- 7 files changed, 78 insertions(+), 78 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 92c6050cd9..f07fa3a6f0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -417,7 +417,7 @@ impl Handle { /// /// Session tokens arrive as [`Event::Session`]. An `AUTHORIZATION TOKEN` on a SUBSCRIBE /// or PUBLISH_NAMESPACE that [`authorize`](Self::authorize) does not cover arrives as - /// [`Event::Scoped`] and decides that request alone. Experimental. + /// [`Event::Request`] and decides that request alone. Experimental. /// /// 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 @@ -449,7 +449,7 @@ impl Handle { /// Ask the acceptor to verify a token that rode on one request. /// - /// The token reaches the application as an [`Event::Scoped`]. The grant it answers covers + /// The token reaches the application as an [`Event::Request`]. The grant it answers covers /// only this request, never joins the session union, and ends when the returned /// [`RequestVerdict`] is dropped. With no [`requests`](Self::requests) consumer the token /// cannot be verified, so the verdict is [`Error::Unsupported`]. `hold` is the request's @@ -469,7 +469,7 @@ impl Handle { ..Default::default() }); // A closed or full queue refuses the token. - queue.push(Event::Scoped(Scoped { + queue.push(Event::Request(Scoped { token, path, kind, @@ -899,14 +899,14 @@ pub enum Event { Session(Request), /// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, answered with a grant only that /// request holds. Experimental. - Scoped(Scoped), + Request(Scoped), } impl Event { fn ended(&self) -> bool { let issue = match self { Self::Session(request) => &request.issue, - Self::Scoped(scoped) => &scoped.issue, + Self::Request(scoped) => &scoped.issue, }; issue.as_ref().expect("unanswered request").read().peer.is_some() } @@ -914,7 +914,7 @@ impl Event { fn reject(self, code: SessionError, reason: &str) { match self { Self::Session(request) => request.reject(code, reason), - Self::Scoped(scoped) => scoped.reject(code, reason), + Self::Request(scoped) => scoped.reject(code, reason), } } } @@ -924,13 +924,13 @@ impl Event { pub(crate) fn session(self) -> Request { match self { Self::Session(request) => request, - Self::Scoped(_) => panic!("expected a session token"), + Self::Request(_) => panic!("expected a session token"), } } - pub(crate) fn scoped(self) -> Scoped { + pub(crate) fn request(self) -> Scoped { match self { - Self::Scoped(scoped) => scoped, + Self::Request(scoped) => scoped, Self::Session(_) => panic!("expected a request token"), } } @@ -989,7 +989,7 @@ impl Pending { } // From here the application owns the verification, so a withdrawal no longer // releases what it holds against the request cap. - if let Event::Scoped(scoped) = &event { + if let Event::Request(scoped) = &event { scoped.issue.as_ref().expect("unanswered request").lock().taken = true; } return Poll::Ready(Some(event)); @@ -1811,7 +1811,7 @@ mod request_token_tests { let mut requests = handle.requests().expect("take the requests"); let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe, None); - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); assert_eq!(request.token(), &token(b"jwt", 7)); assert_eq!(request.path(), &subscribe_path()); assert_eq!(request.kind(), RequestKind::Subscribe); @@ -1832,7 +1832,7 @@ mod request_token_tests { let mut requests = handle.requests().expect("take the requests"); for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind, None); - let request = requests.next().await.expect("a request reaches the acceptor").scoped(); + let request = requests.next().await.expect("a request reaches the acceptor").request(); assert_eq!(request.kind(), kind); let _issued = request.accept(Grant::all()); assert!( @@ -1862,7 +1862,7 @@ mod request_token_tests { .next() .await .unwrap() - .scoped() + .request() .reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); @@ -1875,7 +1875,7 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); + let issued = requests.next().await.unwrap().request().accept(Grant::all()); verdict.grant().await.expect("granted"); drop(verdict); let err = issued.closed().await; @@ -1889,7 +1889,7 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let _issued = requests.next().await.unwrap().scoped().accept(Grant::all()); + let _issued = requests.next().await.unwrap().request().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. @@ -1915,7 +1915,7 @@ mod request_token_tests { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let grant = grant_in(&runtime, 60); let expires = grant.expires; - let _issued = requests.next().await.unwrap().scoped().accept(grant); + let _issued = requests.next().await.unwrap().request().accept(grant); let answered = verdict.grant().await.unwrap(); let request_grant = RequestGrant::new( &runtime, @@ -1940,7 +1940,7 @@ mod request_token_tests { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().scoped().accept(first); + let _issued = requests.next().await.unwrap().request().accept(first); let answered = verdict.grant().await.unwrap(); let mut request_grant = RequestGrant::new( &runtime, @@ -1956,7 +1956,7 @@ mod request_token_tests { let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe, None); let second = grant_in(&runtime, 600); let second_expires = second.expires; - let _issued2 = requests.next().await.unwrap().scoped().accept(second); + let _issued2 = requests.next().await.unwrap().request().accept(second); let renewed = renewal.grant().await; assert!(request_grant.renew(renewal, renewed), "a covering renewal renews"); @@ -1973,7 +1973,7 @@ mod request_token_tests { let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); // Held for the request's life, so only the deadline (not a drop) ends it. - let _issued = requests.next().await.unwrap().scoped().accept(grant_in(&runtime, 60)); + let _issued = requests.next().await.unwrap().request().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); let mut request_grant = RequestGrant::new( &runtime, @@ -2002,7 +2002,7 @@ mod request_token_tests { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().scoped().accept(first); + let _issued = requests.next().await.unwrap().request().accept(first); let answered = verdict.grant().await.unwrap(); let mut request_grant = RequestGrant::new( &runtime, @@ -2020,7 +2020,7 @@ mod request_token_tests { .next() .await .unwrap() - .scoped() + .request() .reject(SessionError::Unauthorized, "bad token"); let refused = renewal.grant().await; assert!(matches!(refused, Err(Error::Session(SessionError::Unauthorized)))); @@ -2041,7 +2041,7 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); + let issued = requests.next().await.unwrap().request().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); let mut request_grant = RequestGrant::new( &runtime, @@ -2097,7 +2097,7 @@ mod request_token_tests { let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); // A grant that never expires, so only the revoke can end the request. - let issued = requests.next().await.unwrap().scoped().accept(Grant::all()); + let issued = requests.next().await.unwrap().request().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); let mut request_grant = RequestGrant::new( &runtime, diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index b79d99e37d..184e2ea896 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -7859,7 +7859,7 @@ mod serve_tests { 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").scoped() }; + let accepted = async { requests.next().await.expect("a request").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:?}"), @@ -8623,10 +8623,10 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s; the renewal never expires. - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); held.push(first.accept(grant_all_expiring(&rt, Some(60)))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap().scoped(); + let renewal = requests.next().await.unwrap().request(); held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -8719,10 +8719,10 @@ mod serve_tests { let release = release.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); held.push(first.accept(grant_all_expiring(&rt, Some(60)))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap().scoped(); + let renewal = requests.next().await.unwrap().request(); release.notified().await; if accept { held.push(renewal.accept(grant_all_expiring(&rt, None))); @@ -8917,16 +8917,16 @@ mod serve_tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); 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().scoped(); + let a = requests.next().await.unwrap().request(); 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().scoped(); + let renewal = requests.next().await.unwrap().request(); held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } @@ -8984,7 +8984,7 @@ mod serve_tests { // Accept the initial token, then never answer a renewal, so the buffer only grows. let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9036,7 +9036,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9081,7 +9081,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9126,7 +9126,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9195,9 +9195,9 @@ mod serve_tests { let acceptor = { let release = release.clone(); async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); - let renewal = requests.next().await.unwrap().scoped(); + let renewal = requests.next().await.unwrap().request(); release.notified().await; renewal.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -9259,7 +9259,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9314,7 +9314,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9354,10 +9354,10 @@ mod serve_tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); let _issued = first.accept(crate::auth::Grant::all()); popped.fetch_add(1, Ordering::Relaxed); - let _never_answered = requests.next().await.expect("a renewal").scoped(); + let _never_answered = requests.next().await.expect("a renewal").request(); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9419,9 +9419,9 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); let _issued = first.accept(crate::auth::Grant::all()); - let _never_answered = requests.next().await.expect("a renewal").scoped(); + let _never_answered = requests.next().await.expect("a renewal").request(); std::future::pending::<()>().await }; let mut acceptor = std::pin::pin!(acceptor); @@ -9465,10 +9465,10 @@ mod serve_tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.unwrap().scoped(); + let first = requests.next().await.unwrap().request(); let _issued = first.accept(grant_all_expiring(&rt, Some(60))); answered.fetch_add(1, Ordering::Relaxed); - let renewal = requests.next().await.unwrap().scoped(); + let renewal = requests.next().await.unwrap().request(); renewal.reject(crate::SessionError::Unauthorized, "no"); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -9541,11 +9541,11 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s. - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").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").scoped(); + let _slow = requests.next().await.expect("a renewal").request(); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9606,7 +9606,7 @@ mod serve_tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); consulted.fetch_add(1, Ordering::Relaxed); held.push(request.accept(crate::auth::Grant::all())); } @@ -9656,7 +9656,7 @@ mod serve_tests { let serving: Serving = Box::pin(async move { let acceptor = async move { let mut held = Vec::new(); - while let Some(crate::auth::Event::Scoped(request)) = requests.next().await { + while let Some(crate::auth::Event::Request(request)) = requests.next().await { held.push(request.accept(grant.clone())); } std::future::pending::>().await @@ -9741,7 +9741,7 @@ mod serve_tests { let acceptor = { let consulted = consulted.clone(); async move { - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); consulted.fetch_add(1, Ordering::Relaxed); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -9867,7 +9867,7 @@ mod serve_tests { if taken.is_none() && let Poll::Ready(Some(event)) = requests.poll_next(&kio::Waiter::noop()) { - taken = Some(event.scoped()); + taken = Some(event.request()); } settle().await; } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 8c20854c9e..cdebf2fcd5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -8638,7 +8638,7 @@ mod tests { let acceptor = async { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); held.push(request.accept(crate::auth::Grant::all())); } }; @@ -8672,7 +8672,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async { - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -8716,7 +8716,7 @@ mod tests { let acceptor = { let consulted = consulted.clone(); async move { - let request = requests.next().await.expect("a request").scoped(); + let request = requests.next().await.expect("a request").request(); consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -8790,7 +8790,7 @@ mod tests { 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").scoped(); + let request = requests.next().await.expect("a request").request(); let _held = request.accept(crate::auth::Grant::all()); std::future::pending::<()>().await }; @@ -8846,10 +8846,10 @@ mod tests { 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").scoped(); + let first = requests.next().await.expect("a request").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").scoped(); + let renewal = requests.next().await.expect("a renewal").request(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await @@ -8920,10 +8920,10 @@ mod tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").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").scoped(); + let renewal = requests.next().await.expect("a renewal").request(); renewal.reject(crate::SessionError::Unauthorized, "no"); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await @@ -9020,9 +9020,9 @@ mod tests { let rt = rt.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); - let renewal = requests.next().await.expect("a renewal").scoped(); + let renewal = requests.next().await.expect("a renewal").request(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); std::future::pending::<()>().await } @@ -9105,15 +9105,15 @@ mod tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").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").scoped(); + let a = requests.next().await.expect("a renewal").request(); 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").scoped(); + let renewal = requests.next().await.expect("a renewal").request(); held.push(renewal.accept(publish_grant_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } @@ -9167,7 +9167,7 @@ mod tests { // 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").scoped(); + let first = requests.next().await.expect("a request").request(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9224,7 +9224,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9267,7 +9267,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9309,7 +9309,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").request(); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9415,7 +9415,7 @@ mod tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request reaches the acceptor").scoped(); + let request = requests.next().await.expect("a request reaches the acceptor").request(); consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); held.push(request.accept(crate::auth::Grant::all())); } @@ -9457,7 +9457,7 @@ mod tests { 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").scoped(); + let request = requests.next().await.expect("a request reaches the acceptor").request(); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -9500,10 +9500,10 @@ mod tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request").scoped(); + let first = requests.next().await.expect("a request").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").scoped(); + let _never_answered = requests.next().await.expect("a renewal").request(); popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await } @@ -9558,7 +9558,7 @@ mod tests { 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").scoped(); + let request = requests.next().await.expect("a request reaches the acceptor").request(); let _issued = request.accept(grant); std::future::pending::<()>().await }; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 9dd12b6463..4290e58db0 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1266,7 +1266,7 @@ mod tests { .next() .await .expect("the pre-ok consumer receives the request") - .scoped(); + .request(); assert_eq!(received.path().as_str(), "room/alice", "{version:?}"); assert_eq!( received.kind(), diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 30185235d2..5a07f53399 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -356,7 +356,7 @@ fn serve( let answered = answer(&token).map(|grant| Answered::Session(request.accept(grant))); (token, answered) } - auth::Event::Scoped(request) => { + auth::Event::Request(request) => { let token = request.token().value.clone(); let answered = match answer(&token) { Some(grant) => Some(Answered::Request(request.accept(grant))), @@ -833,7 +833,7 @@ async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { let mut issued = Vec::new(); let mut count = 0u64; let mut release_waiter = Some(release_waiter); - while let Some(auth::Event::Scoped(request)) = requests.next().await { + while let Some(auth::Event::Request(request)) = requests.next().await { let token = request.token().value.clone(); count += 1; seen_tx.unbounded_send(token).ok(); diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 2132792bf7..165859e080 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -253,7 +253,7 @@ impl Refresh { Event::Request(Some(auth::Event::Session(request))) => self.request(request), // A request token would authorize one request beyond every credential, which // the relay's grants do not model. - Event::Request(Some(auth::Event::Scoped(scoped))) => { + Event::Request(Some(auth::Event::Request(scoped))) => { scoped.reject(SessionError::Unsupported, "request tokens are not verified") } // Dropping a token of a kind added later refuses it. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 9a65ec0732..573ca1a5a5 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2319,7 +2319,7 @@ mod tests { while let Some(event) = requests.next().await { match event { moq_net::auth::Event::Session(req) => sessions.push(req.accept(moq_net::auth::Grant::all())), - moq_net::auth::Event::Scoped(req) => { + moq_net::auth::Event::Request(req) => { if let Some(tx) = first_tx.take() { let _ = tx.send(req.token().value.clone()); } From 838a211e225d6fdf08b2fd15c028cfb433a3a36a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 14:28:30 -0700 Subject: [PATCH 06/53] fix(auth): refuse an expired request grant and keep the acceptor's reason A request token answered with a grant that had already expired was admitted until its deadline fired, so the announce or subscription was briefly visible. It is now refused up front, and an expired renewal ends the request instead of being acknowledged. A refusal from Scoped::reject now reaches the peer with the code and reason the application chose, on the initial request and on a renewal, instead of a generic UNAUTHORIZED "renewal not granted". Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 158 +++++++++++++++++++++----- rs/moq-net/src/ietf/publisher.rs | 24 ++-- rs/moq-net/src/ietf/request_update.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 15 ++- 4 files changed, 149 insertions(+), 50 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index f07fa3a6f0..ec94ff9f64 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1213,20 +1213,43 @@ 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)] +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 } + } +} + impl RequestVerdict { /// Wait for the acceptor's first answer. See [`poll_grant`](Self::poll_grant). #[cfg(test)] - pub(crate) async fn grant(&self) -> Result { + 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 a refusal as the - /// [`Error`] whose request code the caller sends the peer. No consumer is - /// [`Error::Unsupported`]; an unanswered (dropped) request is - /// [`SessionError::Unauthorized`]. - pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { + /// 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`]. + pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { let Some(issue) = &self.issue else { - return Poll::Ready(Err(Error::Unsupported)); + return Poll::Ready(Err(Error::Unsupported.into())); }; let mut guard = ready_or!( issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { @@ -1236,9 +1259,9 @@ impl RequestVerdict { ); Poll::Ready(match guard.outbox.pop_front() { Some(Reply::Grant(grant)) => Ok(grant), - Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), + Some(Reply::Refuse { code, reason }) => Err(Refusal::new(Error::Session(code), &reason)), // Done with nothing written: the acceptor dropped the request unanswered. - None => Err(Error::Session(SessionError::Unauthorized)), + None => Err(Error::Session(SessionError::Unauthorized).into()), }) } @@ -1301,23 +1324,24 @@ pub(crate) struct RequestGrant { kind: RequestKind, /// The request's slot, shared with a renewal's verification. hold: Option, + clock: crate::time::Clock, } impl RequestGrant { /// Hold the acceptor's first answer `res` for the request's life, armed to lapse at its - /// expiry, or the error to refuse the request with: the refusal, or - /// [`Error::Unauthorized`] for a grant that does not cover it. + /// 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: Result, + res: std::result::Result, path: crate::PathOwned, kind: RequestKind, hold: Option, - ) -> Result { + ) -> std::result::Result { let grant = res?; - if !kind.covers(&grant, path.as_str()) { - return Err(Error::Unauthorized); + 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); @@ -1328,6 +1352,7 @@ impl RequestGrant { path, kind, hold, + clock: clock.clone(), }) } @@ -1347,21 +1372,25 @@ impl RequestGrant { handle.verify_request(token, self.path.clone(), self.kind, self.hold.clone()) } - /// Apply a renewal's verdict, returning whether it renewed the request. + /// Apply a renewal's verdict, or return the refusal that ends the request. /// - /// A 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, changes nothing here; - /// the caller ends only that request. - pub(crate) fn renew(&mut self, verdict: RequestVerdict, res: Result) -> bool { - let grant = match res { - Ok(grant) if self.covers(&grant) => grant, - _ => return false, - }; + /// 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( + &mut self, + verdict: RequestVerdict, + res: std::result::Result, + ) -> std::result::Result<(), Refusal> { + let grant = res?; + if !live(&self.clock, self.kind, self.path.as_str(), &grant) { + return Err(Refusal::new(Error::Unauthorized, "renewal not granted")); + } self.verdict = verdict; self.deadline.set(grant.expires); self.grant = grant; - true + Ok(()) } /// Resolve with the error that ends the request: the deadline lapsing @@ -1392,6 +1421,12 @@ impl RequestGrant { } } +/// 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. +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 session tokens, which the peer is told. Dropping it /// ends the grant. pub struct Issued { @@ -1848,7 +1883,7 @@ mod request_token_tests { let handle = Handle::new(true); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let err = verdict.grant().await.expect_err("no verifier"); - assert!(matches!(err, Error::Unsupported), "{err:?}"); + assert!(matches!(err.error, Error::Unsupported), "{err:?}"); } /// A refused request token resolves to the refusal, which the caller sends the peer as @@ -1865,7 +1900,10 @@ mod request_token_tests { .request() .reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); - assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + assert!( + matches!(err.error, Error::Session(SessionError::Unauthorized)), + "{err:?}" + ); } /// The grant lives exactly as long as the request: dropping the verdict ends the @@ -1930,6 +1968,55 @@ mod request_token_tests { 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), subscribe_path(), RequestKind::Subscribe, None); + let _issued = requests.next().await.unwrap().request().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), subscribe_path(), RequestKind::Subscribe, None); + let _issued = requests.next().await.unwrap().request().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await; + let mut request_grant = RequestGrant::new( + &runtime, + verdict, + answered, + subscribe_path(), + RequestKind::Subscribe, + None, + ) + .unwrap(); + let renewal = handle.verify_request(token(b"old2", 0), subscribe_path(), RequestKind::Subscribe, None); + let _issued = requests.next().await.unwrap().request().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() { @@ -1958,7 +2045,10 @@ mod request_token_tests { let second_expires = second.expires; let _issued2 = requests.next().await.unwrap().request().accept(second); let renewed = renewal.grant().await; - assert!(request_grant.renew(renewal, renewed), "a covering renewal renews"); + 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); @@ -2023,8 +2113,14 @@ mod request_token_tests { .request() .reject(SessionError::Unauthorized, "bad token"); let refused = renewal.grant().await; - assert!(matches!(refused, Err(Error::Session(SessionError::Unauthorized)))); - assert!(!request_grant.renew(renewal, refused), "a refusal does not renew"); + 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. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 184e2ea896..afced2da4a 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -840,7 +840,11 @@ where ), Some(grant), ), - Err(err) => return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await, + Err(refusal) => { + return self + .reject_subscribe(stream, request_id, &refusal.error, &refusal.reason) + .await; + } } } None => ( @@ -1228,23 +1232,23 @@ where let rg = request_grant.as_mut().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. - if rg.renew(verdict, res) { + 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, &Error::Unauthorized, "renewal not granted") + .write_subscribe_error(&mut stream.writer, rid, &refusal.error, &refusal.reason) .await { return Some(Served::Track(Err(err))); } - return Some(Served::UpdateFailed(Error::Unauthorized)); + return Some(Served::UpdateFailed(refusal.error)); } // An update behind a pending verdict waits its turn, so its answer follows the // verdict's. @@ -8811,7 +8815,7 @@ mod serve_tests { "{version:?}: the renewal was answered" ); assert!( - matches!(result, Some(Err(Error::Unauthorized))), + matches!(result, Some(Err(Error::Session(crate::SessionError::Unauthorized)))), "{version:?}: a refused renewal ends the subscription at once, got {result:?}" ); let (status, _) = Wire::read(&h.log, version).publish_done.expect("PUBLISH_DONE was sent"); @@ -8827,7 +8831,7 @@ mod serve_tests { Version::Draft14 => {} // draft-15/16 answer with one keyed REQUEST_ERROR before the subscription ends. _ => { - let error = request_error_bytes(version, RequestId(0x40), "renewal not granted").await; + 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" @@ -9229,7 +9233,7 @@ mod serve_tests { settle().await; } let written = h.log.writes.lock().unwrap()[before..].to_vec(); - let error = request_error_bytes(version, RequestId(0x40), "renewal not granted").await; + 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" @@ -9494,7 +9498,7 @@ mod serve_tests { "acceptor never answered both tokens" ); assert!( - matches!(result, Some(Err(Error::Unauthorized))), + 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"); @@ -9506,7 +9510,7 @@ mod serve_tests { // 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), "renewal not granted").await; + let error = request_error_bytes(VERSION, RequestId(0), "no").await; assert_eq!( written.windows(error.len()).filter(|w| *w == error.as_slice()).count(), 1, diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs index 4a7b96cdcb..1f90b6bf7a 100644 --- a/rs/moq-net/src/ietf/request_update.rs +++ b/rs/moq-net/src/ietf/request_update.rs @@ -129,7 +129,7 @@ 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: crate::Result, + pub grant: Result, /// The update that carried the renewal, which the answer names before draft 17. pub request_id: RequestId, } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index cdebf2fcd5..a813ec3c19 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1347,8 +1347,8 @@ where ); match admitted { Ok(grant) => (Admit::Token(slot), Some(grant)), - Err(err) => { - self.write_error(&mut stream, request_id, &err, &err.to_string()) + Err(refusal) => { + self.write_error(&mut stream, request_id, &refusal.error, &refusal.reason) .await?; let _ = stream.writer.close().await; return Ok(()); @@ -1557,16 +1557,15 @@ where // Only a token-authorized announce starts a renewal. let rg = token_grant.as_mut().expect("a renewal without a request grant"); // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). - if rg.renew(verdict, res) { + 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. - self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") - .await?; - self.cancel_announce(stream, request_id, path, &Error::Unauthorized, "renewal not granted") + self.write_error(stream, rid, &refusal.error, &refusal.reason).await?; + self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) .await?; return Ok(()); } @@ -8977,7 +8976,7 @@ mod tests { request::Kind::PublishNamespace, version, ), - reason_phrase: "renewal not granted".into(), + reason_phrase: "no".into(), }) .await .unwrap(); From fed4a874c9a3e3381d0a2f0825defb640efde1ef Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 14:51:07 -0700 Subject: [PATCH 07/53] fix(tokio): a request token on a moq-lite session fails the dial for good The same server negotiates moq-lite again, so retrying a session that cannot carry the configured request token only spins until the retry window ends. It now fails with the terminal Error::RequestTokenUnsupported. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/client.rs | 14 ++++++++------ rs/moq-tokio/src/error.rs | 11 +++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index a34a1cde02..a68389376b 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -559,7 +559,6 @@ impl Client { Err(err) => { // The fallback got through but its MoQ handshake did not. A QUIC dial still // pending may yet connect, bounded by the same deadline as the race. - let err = Error::from(err); let quic = match quic { Ok(dial) => { tracing::warn!(%err, "WebSocket handshake failed; waiting on QUIC"); @@ -576,7 +575,7 @@ impl Client { // Both handshakes failing is still a two-arm loss: a mixed auth pair stays retryable. let session = connect_session(moq, quic, token) .await - .map_err(|quic| race_error(quic.into(), err))?; + .map_err(|quic| race_error(quic, err))?; return Ok(Dialed::new(session, transport)); } }; @@ -586,7 +585,7 @@ impl Client { let token = token.clone(); let dial = Box::pin(async move { let quic = quic.await?; - Ok(Box::pin(async move { Ok(connect_session(&moq, quic, &token).await?) }) as Handshake) + Ok(Box::pin(async move { connect_session(&moq, quic, &token).await }) as Handshake) }); Upgrade::new(dial, transport) }); @@ -911,13 +910,16 @@ async fn connect_session( client: &moq_net::Client, transport: S, token: &RequestToken, -) -> Result { +) -> crate::Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; // Before the driver runs, so the first request carries the newest token. A session that - // cannot carry one (moq-lite) fails the dial rather than drop it. - token.attach(session.auth())?; + // cannot carry one (moq-lite) fails the dial for good rather than drop it. + token.attach(session.auth()).map_err(|err| match err { + moq_net::Error::Unsupported => Error::RequestTokenUnsupported, + err => err.into(), + })?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 29ff84c9b2..4b7154a7b8 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -91,6 +91,10 @@ pub enum Error { #[error("qlog capture requires the 'qlog' feature")] QlogUnsupported, + /// A request token is configured, but the session negotiated moq-lite, which cannot carry one. + #[error("a request token needs moq-transport, but the session negotiated moq-lite")] + RequestTokenUnsupported, + /// The config was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] @@ -244,6 +248,7 @@ impl Error { | Self::IdleTimeoutRange | Self::WindowRange(_) | Self::QlogUnsupported + | Self::RequestTokenUnsupported | Self::Connect(_) => true, #[cfg(feature = "noq")] Self::Noq(err) => err.is_terminal(), @@ -391,6 +396,12 @@ pub type Result = std::result::Result; mod tests { use super::*; + /// The same server negotiates moq-lite again, so a token it cannot carry fails the dial for good. + #[test] + fn a_request_token_on_moq_lite_is_terminal() { + assert!(Error::RequestTokenUnsupported.is_terminal()); + } + #[test] fn a_transport_race_is_terminal_only_when_every_arm_is_terminal() { let local = || Error::WebSocket(Arc::new(crate::websocket::Error::UnsupportedScheme("bad".into()))); From 31fd1463474d5af7e549f9741ef90c35032c31d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 16:06:38 -0700 Subject: [PATCH 08/53] refactor(tokio): an Unsupported dial is terminal, without a token variant Drop Error::RequestTokenUnsupported. Any moq_net::Error::Unsupported during a dial is now terminal: the same peer negotiates the same way again, so a retry cannot succeed. A request token on a moq-lite session is one such case and still fails at once. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/client.rs | 8 +++---- rs/moq-tokio/src/connect.rs | 3 ++- rs/moq-tokio/src/connection.rs | 40 ++++++++++++++++++++++++++++++++++ rs/moq-tokio/src/error.rs | 15 ++++++------- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index a68389376b..4fa011408d 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -915,11 +915,9 @@ async fn connect_session( .connect(tokio::time::Instant::now().into_std(), transport) .await?; // Before the driver runs, so the first request carries the newest token. A session that - // cannot carry one (moq-lite) fails the dial for good rather than drop it. - token.attach(session.auth()).map_err(|err| match err { - moq_net::Error::Unsupported => Error::RequestTokenUnsupported, - err => err.into(), - })?; + // cannot carry one (moq-lite) fails the dial as Unsupported, which is terminal, rather + // than drop it. + token.attach(session.auth())?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index fa46a3fb23..2eec4650f5 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -762,7 +762,8 @@ impl Config { /// PUBLISH_NAMESPACE requests. Experimental: the peer's application must verify it. /// /// It seeds the connection's request token, which [`crate::Auth::set_request_token`] - /// replaces across reconnects. + /// replaces across reconnects. A session that negotiates moq-lite cannot carry it, so + /// the connection ends with [`moq_net::Error::Unsupported`] instead of retrying. pub fn with_request_token(mut self, token: moq_net::setup::Token) -> Self { self.request_token = Some(RequestToken(token)); self diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 573ca1a5a5..3cc16572de 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2280,6 +2280,46 @@ mod tests { ); } + /// A request token on a moq-lite session fails the connection at once, even with unlimited + /// reconnects: the server negotiates moq-lite again, so a retry cannot carry the token. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_request_token_on_moq_lite_fails_at_once() { + let mut cfg = crate::listen::Config::default(); + cfg.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + cfg.version = vec!["moq-lite-06".parse().unwrap()]; + let mut listener = cfg + .init(Default::default()) + .expect("server") + .listen() + .await + .expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); + tokio::spawn(async move { + let mut sessions = Vec::new(); + while let Some(request) = listener.accept().await { + if let Ok(session) = request.ok().await { + sessions.push(session); + } + } + }); + + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + config.backoff.timeout = Some(Duration::ZERO); + let client = config + .with_request_token(tok(b"token")) + .init(Default::default()) + .expect("client"); + let connection = client.connect(url(&format!("tcp://{addr}/"))); + let err = tokio::time::timeout(Duration::from_secs(10), connection.closed()) + .await + .expect("kept retrying a session that cannot carry the token") + .expect_err("connected without carrying the token"); + assert!(matches!(err, Error::MoqNet(moq_net::Error::Unsupported)), "{err}"); + } + /// A draft-16 server for the first-request tests: it accepts one session, reports on /// `paused` once the client's SETUP has arrived (the client's handshake then waits on this /// side's reply), holds its SERVER_SETUP until `release` fires, and sends the first diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 4b7154a7b8..8d020a0fc0 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -91,10 +91,6 @@ pub enum Error { #[error("qlog capture requires the 'qlog' feature")] QlogUnsupported, - /// A request token is configured, but the session negotiated moq-lite, which cannot carry one. - #[error("a request token needs moq-transport, but the session negotiated moq-lite")] - RequestTokenUnsupported, - /// The config was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] @@ -248,8 +244,11 @@ impl Error { | Self::IdleTimeoutRange | Self::WindowRange(_) | Self::QlogUnsupported - | Self::RequestTokenUnsupported | Self::Connect(_) => true, + // Something this dial needs the negotiated session cannot do, such as carry a + // configured request token on moq-lite. The same peer negotiates the same way + // again, so retrying cannot help. + Self::MoqNet(moq_net::Error::Unsupported) => true, #[cfg(feature = "noq")] Self::Noq(err) => err.is_terminal(), #[cfg(feature = "websocket")] @@ -396,10 +395,10 @@ pub type Result = std::result::Result; mod tests { use super::*; - /// The same server negotiates moq-lite again, so a token it cannot carry fails the dial for good. + /// The same peer negotiates the same way again, so a dial it cannot support fails for good. #[test] - fn a_request_token_on_moq_lite_is_terminal() { - assert!(Error::RequestTokenUnsupported.is_terminal()); + fn an_unsupported_dial_is_terminal() { + assert!(Error::MoqNet(moq_net::Error::Unsupported).is_terminal()); } #[test] From 06f482ce54dc31fc6869964ff82f65fcd0e0d371 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 17:07:03 -0700 Subject: [PATCH 09/53] fix(net): stand grant enforcement down for a request token set later Dialing-side grant enforcement read the request token once at startup, so a token set afterwards through Connection::auth() still closed the session when it announced outside the connection grant, before the peer could verify the token its PUBLISH_NAMESPACE carried. Enforcement now rechecks on every token change. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/session.rs | 77 +++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 9c85294050..bc3aa0b888 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1421,14 +1421,27 @@ async fn enforce_grant( // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still - // bounds what we publish. - if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { - return Ok(()); - } + // bounds what we publish. The token can be set at any time, so each change is rechecked. let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); - let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { - return Ok(()); + let path = loop { + let token = request_token.peek(); + if token.is_some() && announces_carry_token(&peer_setup, version).await { + return Ok(()); + } + let next = kio::wait(|waiter| { + if request_token.poll_changed(&token, waiter).is_ready() { + return std::task::Poll::Ready(None); + } + check.poll(&auth, &mut announced, waiter).map(Some) + }) + .await; + match next { + // The token changed: recheck whether announces now carry it. + None => continue, + Some(None) => return Ok(()), + Some(Some(path)) => break path, + } }; tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); let err = Error::Unauthorized; @@ -1952,6 +1965,58 @@ mod tests { ); } + /// A request token set after the session started stands enforcement down just the same: + /// the announce that follows carries it, so the peer decides, not the connection grant. + #[moq_net_sim::test] + async fn a_request_token_set_later_stands_enforcement_down() { + let auth = crate::auth::Handle::new(true); + let setup = auth.present(bytes::Bytes::new(), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("room/alice").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(false), + ..Default::default() + }); + let request_token = crate::RequestToken::new(None); + + let mut enforce = std::pin::pin!(enforce_grant( + auth, + origin.consume(), + session, + request_token.clone(), + peer_setup, + Version::Draft18, + )); + assert!(futures::poll!(enforce.as_mut()).is_pending(), "nothing to enforce yet"); + + request_token.set(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })); + let _cam = origin + .announce("room/bob/cam", crate::origin::Route::default()) + .unwrap(); + let result = moq_net_sim::timeout(std::time::Duration::from_secs(1), enforce) + .await + .expect("enforcement never noticed the token"); + assert!(result.is_ok(), "{result:?}"); + assert!( + log.closes().is_empty(), + "the session stays open for the token-bearing announce" + ); + drop(setup); + } + /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client /// advertise outside its connection grant there: the grant is still enforced. From 5adfd84a64e4d72df178d7e9952faaf168cf622b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 19:24:32 -0700 Subject: [PATCH 10/53] docs: drop a line duplicated by the rebase onto main Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 8c5c6a4077..9a4a6fde0f 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -30,7 +30,6 @@ subset drops. What a peer actually observes against this implementation: - **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. -- **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. From 36c18f9478988ba556b6c41946bfec90c586f9a2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 20:54:43 -0700 Subject: [PATCH 11/53] fix(net): an announce ending under a request token ends only itself A lapsed or refused announce grant releases its route before the withdrawal waits on the peer, so a peer that stops reading cannot keep it routed. A token refresh whose advertisement the peer ends (no answer, a closed stream, or a draft-16 PUBLISH_NAMESPACE_CANCEL) drops and re-offers only that namespace instead of closing the session. Document that a draft 14 or 15 advertisement keeps the token it was offered with. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 3 +- rs/moq-net/src/ietf/publisher.rs | 117 +++++++++++++++++++++++++- rs/moq-net/src/ietf/subscriber.rs | 63 ++++++++++++++ rs/moq-net/src/lite/test_transport.rs | 13 ++- rs/moq-tokio/src/connection/auth.rs | 3 +- 5 files changed, 192 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ec94ff9f64..c9ab6bd7c3 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -334,7 +334,8 @@ impl Handle { /// Set it before running the session's driver to present it on the first request. /// Replacing it later re-presents the new token on every live request as a /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value - /// again sends nothing. Independent of the MoQ Auth extension. + /// again sends nothing. A draft 14 or 15 advertisement has no update, so it keeps the + /// token it was offered with. Independent of the MoQ Auth extension. /// /// A moq-lite session has no request token to carry it, so it refuses with /// [`Error::Unsupported`] rather than drop it. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index afced2da4a..b025b1ce4e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2835,12 +2835,31 @@ where cost: None, authorization_token: Some(crate::ietf::token::RequestToken(token.clone())), }; - request.stream.writer.encode_message(&update).await?; + let answer = match request.stream.writer.encode_message(&update).await { + Ok(()) => self.read_response(&mut request.stream).await, + Err(err) => Err(err), + }; let absolute = self.origin.absolute(&request.path).to_owned(); - let Some((type_id, body)) = self.read_response(&mut request.stream).await? else { - tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); - // The peer never answered: the stream is gone, so drop the advertisement. + // The old grant can lapse while this waits, and the peer then ends only this + // advertisement: by never answering, closing the stream, or (draft-16) sending + // PUBLISH_NAMESPACE_CANCEL on it. That drops the advertisement, never the session. + let answer = match answer { + Ok(Some((type_id, _))) if type_id == ietf::PublishNamespaceCancel::ID && legacy => { + tracing::debug!(broadcast = %absolute, "advertisement cancelled during the token refresh"); + None + } + Ok(Some(answer)) => Some(answer), + Ok(None) => { + tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); + None + } + Err(err) => { + tracing::debug!(broadcast = %absolute, %err, "advertisement ended during the token refresh"); + None + } + }; + let Some((type_id, body)) = answer else { ns.requests.remove(&suffix); // Forget what was sent so reconcile re-offers it with the new token, and re-arm // the deferred-only retry sweep if an earlier refusal still holds it back. @@ -12240,6 +12259,96 @@ mod tests { ); } + /// A peer that ends the advertisement while a token refresh waits for its answer (its old + /// grant lapsed) ends only that advertisement: the namespace is re-offered with the new + /// token and the session stays up. Draft-16 cancels it on the stream, draft-17+ closes it. + #[moq_net_sim::test] + async fn an_advertisement_ended_during_a_token_refresh_is_re_offered() { + for version in [Version::Draft16, Version::Draft18] { + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Stream 1: the PUBLISH_NAMESPACE is accepted, then the peer ends it. Stream 2: the + // re-offer is accepted. + let mut stream1 = publish_namespace_ok(version).await; + let session = match version { + Version::Draft16 => { + let cancel = crate::lite::test_transport::Log::default(); + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(cancel.clone()), version) + .encode_message(&ietf::PublishNamespaceCancel { + track_namespace: crate::Path::new("cam"), + request_id: RequestId(0), + error_code: 0x1, + reason_phrase: "expired".into(), + }) + .await + .unwrap(); + stream1.extend(cancel.writes.lock().unwrap().iter()); + crate::lite::test_transport::ScriptedSession::per_stream(vec![ + stream1, + publish_namespace_ok(version).await, + ]) + } + _ => crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + stream1, + publish_namespace_ok(version).await, + ]), + }; + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(typed(&first))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, &first), + 1, + "{version}: the initial token rode the announce" + ); + + token.set(Some(typed(&second))); + let mut reoffered = false; + for _ in 0..300 { + assert!( + futures::poll!(run.as_mut()).is_pending(), + "{version}: an ended advertisement must not end the namespace loop" + ); + if occurrences(&log, &second) >= 2 { + reoffered = true; + break; + } + settle().await; + } + assert!( + reoffered, + "{version}: the ended namespace is re-offered with the new token" + ); + assert!(log.closes().is_empty(), "{version}: the session stays up"); + } + } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged /// token: a receiver renewing a token-authorized announce answers a token-bearing update as /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index a813ec3c19..1f161525d1 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1545,6 +1545,11 @@ where } Turn::Failed(err) => return Err(err), Turn::Ended(err) => { + // Retract the route before the withdrawal, which waits on the peer: a lapsed + // grant serves nothing more, however slowly the peer reads. + if std::mem::take(attached) { + self.stop_announce(path.clone(), true)?; + } self.cancel_announce(stream, request_id, path, &err, "request token grant ended") .await?; return Err(err); @@ -1564,6 +1569,9 @@ where // 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. + if std::mem::take(attached) { + self.stop_announce(path.clone(), true)?; + } self.write_error(stream, rid, &refusal.error, &refusal.reason).await?; self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) .await?; @@ -8903,6 +8911,61 @@ mod tests { } } + /// 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").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" + ); + } + /// 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. diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index bf6fc1bf45..8595999a49 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -652,6 +652,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, } impl ScriptedSession { @@ -665,9 +667,16 @@ 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, } } + /// 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). /// @@ -792,8 +801,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-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 33c13bcef3..b5f8ee6f26 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -22,7 +22,8 @@ impl Auth { /// PUBLISH_NAMESPACE requests, now and after reconnects. Experimental: the peer's /// application must verify it, and success means only that the token was set. /// - /// Replacing it renews the live session's token-authorized requests in band. Errors + /// Replacing it renews the live session's token-authorized requests in band, except a + /// draft 14 or 15 advertisement, which has no update. Errors /// with [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose /// requests carry no token; later sessions are still seeded with it. pub fn set_request_token(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { From 59b1daf2a68e8ab89b440fd83e0ada5e98b822cf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 13 Oct 2026 22:33:02 -0700 Subject: [PATCH 12/53] fix(net): a malformed token refresh answer still ends the session Only a closed stream (EOF, reset, timeout, or a draft-16 cancel) drops just the advertisement. Any other decode or framing violation in the answer is the peer's and propagates as before. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 7a818fb897..810ac118a0 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2877,6 +2877,14 @@ where tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); None } + // A malformed answer is the peer's violation, not a closed stream, and ends the + // session. A stream that ends mid-message reads as Short, which is a closure. + Err(err) + if super::subscriber::is_protocol_violation(&err) + && !matches!(err, Error::Decode(DecodeError::Short)) => + { + return Err(err); + } Err(err) => { tracing::debug!(broadcast = %absolute, %err, "advertisement ended during the token refresh"); None @@ -12713,6 +12721,64 @@ mod tests { } } + /// A malformed answer to a token refresh is the peer's violation, not a closed stream: it + /// still ends the namespace loop, and with it the session. + #[moq_net_sim::test] + async fn a_malformed_token_refresh_answer_stays_fatal() { + const VERSION: Version = Version::Draft17; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // The PUBLISH_NAMESPACE is accepted, then the renewal is answered with a message type in + // the seven-byte varint form draft-17 forbids. + let mut stream1 = publish_namespace_ok(VERSION).await; + stream1.extend([0xfc, 0, 0, 0, 0, 0, 0]); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(typed(&first))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); + + token.set(Some(typed(&second))); + 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(Err(Error::Decode(DecodeError::InvalidValue)))), + "a malformed answer must stay fatal, got {result:?}" + ); + } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged /// token: a receiver renewing a token-authorized announce answers a token-bearing update as /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. From c414e94cebc98cc0a9a18f9a6f6594bef9c5dc53 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 00:01:19 -0700 Subject: [PATCH 13/53] fix(net): keep a revocation's reason, an announce's slot, and lite's refusal A revoked or lapsed announce grant sends the acceptor's code and reason in PUBLISH_NAMESPACE_CANCEL. A token-authorized announce re-attaching after a reflected path reuses the slot its grant holds instead of taking a second. A moq-lite server handshake refuses a request token before ok(), not only once the session starts. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 40 +++++++---- rs/moq-net/src/ietf/publisher.rs | 7 +- rs/moq-net/src/ietf/subscriber.rs | 112 +++++++++++++++++++++++++++--- rs/moq-net/src/server.rs | 17 +++++ 4 files changed, 151 insertions(+), 25 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index c9ab6bd7c3..709bb4a2f6 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1368,6 +1368,11 @@ impl RequestGrant { &self.grant } + /// The request's slot, for re-attaching what it authorized without taking another. + pub(crate) fn hold(&self) -> Option { + self.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 { handle.verify_request(token, self.path.clone(), self.kind, self.hold.clone()) @@ -1394,20 +1399,20 @@ impl RequestGrant { Ok(()) } - /// Resolve with the error that ends the request: the deadline lapsing - /// ([`Error::Unauthorized`]), the acceptor revoking or dropping the grant (its code), 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(&mut self, waiter: &kio::Waiter) -> Poll { + /// 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(&mut self, waiter: &kio::Waiter) -> Poll { loop { if self.deadline.poll(waiter).is_ready() { - return Poll::Ready(Error::Unauthorized); + return Poll::Ready(Refusal::new(Error::Unauthorized, "request token expired")); } match self.verdict.poll_reply(waiter) { Poll::Ready(Reply::Grant(grant)) => { if !self.covers(&grant) { - return Poll::Ready(Error::Unauthorized); + return Poll::Ready(Refusal::new(Error::Unauthorized, "request token no longer granted")); } self.deadline.set(grant.expires); self.grant = grant; @@ -1415,7 +1420,9 @@ impl RequestGrant { // be waiting. continue; } - Poll::Ready(Reply::Refuse { code, .. }) => return Poll::Ready(Error::Session(code)), + Poll::Ready(Reply::Refuse { code, reason }) => { + return Poll::Ready(Refusal::new(Error::Session(code), &reason)); + } Poll::Pending => return Poll::Pending, } } @@ -2076,7 +2083,7 @@ mod request_token_tests { ) .unwrap(); - let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + 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"); } @@ -2126,7 +2133,7 @@ mod request_token_tests { // 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; + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await.error; assert!(matches!(err, Error::Unauthorized), "{err:?}"); } @@ -2161,7 +2168,8 @@ mod request_token_tests { kio::wait(|waiter| request_grant.poll_ended(waiter)), ) .await - .expect("an uncovering update must end the request"); + .expect("an uncovering update must end the request") + .error; assert!(matches!(err, Error::Unauthorized), "{err:?}"); } @@ -2207,8 +2215,12 @@ mod request_token_tests { .unwrap(); issued.revoke(SessionError::Unauthorized, "revoked"); - let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; - assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + 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/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 810ac118a0..a8ac84de16 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1149,10 +1149,11 @@ where return Poll::Ready(Turn::Cancelled); } if let Some(rg) = request_grant.as_mut() - && let Poll::Ready(err) = rg.poll_ended(waiter) + && let Poll::Ready(refusal) = rg.poll_ended(waiter) { - tracing::info!(broadcast = %namespace, %err, "request token grant ended"); - return Poll::Ready(Turn::Ended(err)); + // PUBLISH_DONE carries a fixed reason per status, so only the code reaches the peer. + tracing::info!(broadcast = %namespace, err = %refusal.error, reason = %refusal.reason, "request token grant ended"); + return Poll::Ready(Turn::Ended(refusal.error)); } if let Poll::Ready(renewed) = renewal.poll(waiter) { return Poll::Ready(Turn::Renewal(renewed)); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index dd0da8f708..355d4a0fed 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1458,7 +1458,7 @@ where // The read failed. Failed(Error), // The request grant lapsed or was revoked: the announce ends, never the session. - Ended(Error), + Ended(crate::auth::Refusal), // A pending renewal's verdict resolved, for the update with this request id. Renewal(request_update::Verdict), } @@ -1477,9 +1477,9 @@ where return Poll::Ready(Turn::WriterClosed(result)); } if let Some(rg) = token_grant.as_mut() - && let Poll::Ready(err) = rg.poll_ended(waiter) + && let Poll::Ready(refusal) = rg.poll_ended(waiter) { - return Poll::Ready(Turn::Ended(err)); + return Poll::Ready(Turn::Ended(refusal)); } if let Poll::Ready(renewed) = renewal.poll(waiter) { return Poll::Ready(Turn::Renewal(renewed)); @@ -1546,15 +1546,15 @@ where return stream.writer.closed().await; } Turn::Failed(err) => return Err(err), - Turn::Ended(err) => { + Turn::Ended(refusal) => { // Retract the route before the withdrawal, which waits on the peer: a lapsed // grant serves nothing more, however slowly the peer reads. if std::mem::take(attached) { self.stop_announce(path.clone(), true)?; } - self.cancel_announce(stream, request_id, path, &err, "request token grant ended") + self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) .await?; - return Err(err); + return Err(refusal.error); } Turn::Renewal(request_update::Verdict { verdict, @@ -1672,8 +1672,9 @@ where true => self.update_announce(path.clone(), advert), // Re-attach: a clean path replaced the reflected one we detached from. false => { - let admit = match token_grant { - Some(_) => Admit::Token(None), + // 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) @@ -7908,6 +7909,101 @@ mod tests { ); } + /// 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 a_token_announce_reattaches_on_its_own_slot() { + let self_origin = crate::Hop::new(5).unwrap(); + 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: 300, + value: vec![0x00, 0xff], + }; + let verdict = auth.verify_request( + token, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + Some(hold.clone()), + ); + let _issued = requests + .next() + .await + .unwrap() + .request() + .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 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, + RequestId(1), + &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. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 3dfa29f7ea..7ebeecc359 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -297,6 +297,8 @@ impl Server { } PausedHandshake::Boxed(_) => false, }); + // Refuse a request token set before `ok()` too. + auth.lite(); Ok(Handshake { path, role, @@ -1371,6 +1373,21 @@ mod tests { assert_eq!(request.role(), None, "a client that omits the role is bidirectional"); } + /// moq-lite carries no request token, so setting one before `ok()` already refuses. + #[moq_net_sim::test] + async fn a_lite_handshake_refuses_a_request_token() { + let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); + let request = Server::new().accept_request(moq_net_sim::now(), session).await.unwrap(); + let token = crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + }; + assert!(matches!( + request.auth().set_request_token(token), + Err(Error::Unsupported) + )); + } + #[moq_net_sim::test] async fn accept_request_lite05_without_path_is_empty() { let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); From 115b5397e630ab0cb3a9d4034c67a967af89d991 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 00:38:06 -0700 Subject: [PATCH 14/53] fix(net): release an overflowed announce first, and mark legacy lite A renewal flood past the local guard releases the announce's route before the withdrawal waits on the peer. The legacy server handshake marks a lite session's auth handle lite, so a request token set before ok() is refused. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 52 +++++++++++++++++++++++++++++++ rs/moq-net/src/server.rs | 9 ++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 355d4a0fed..22f3845ce6 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1517,6 +1517,10 @@ where // 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) => { + // Release the route first: the withdrawal waits on the peer. + if std::mem::take(attached) { + self.stop_announce(path.clone(), token_grant.is_some())?; + } if stream.writer.finish().is_ok() { let _ = stream.writer.closed().await; } @@ -9505,6 +9509,54 @@ mod tests { ); } + /// 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").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( diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 7ebeecc359..7742e6d259 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -451,14 +451,19 @@ impl Server { Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; + // Lite 01/02 and moq-transport 14-16 carry no AUTH. + let auth = crate::auth::Handle::new(false); + // Refuse a request token set before `ok()` too. + if matches!(version, Version::Lite(_)) { + auth.lite(); + } Ok(Handshake { path, role: None, origin: None, token, assigned_hop: crate::Hop::random(), - // Lite 01/02 and moq-transport 14-16 carry no AUTH. - auth: crate::auth::Handle::new(false), + auth, inner: Some(RequestInner { server: self.clone(), runtime, From 076563a83a253e87be7da428d9ad67f3b48f0ecd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 01:15:36 -0700 Subject: [PATCH 15/53] fix(net): close three request-token races in admission and renewal Taking a pending verification checks for its withdrawal under the same lock that marks it taken, so a racing withdrawal cannot strand it without a slot. On drafts 14-16 a terminal message waiting behind a verification withdraws the request at once. A SUBSCRIBE's renewal tracking starts from the token it carried, so a replacement set before SUBSCRIBE_OK is still renewed. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 25 ++++-- rs/moq-net/src/ietf/request_stream.rs | 15 +++- rs/moq-net/src/ietf/subscriber.rs | 125 +++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 709bb4a2f6..999063275c 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -912,6 +912,21 @@ impl Event { issue.as_ref().expect("unanswered request").read().peer.is_some() } + /// Hand the event to the application, or `false` when its request already ended. A + /// request token is marked taken under the same lock its withdrawal checks, so from here + /// the application owns its slot until it answers. + fn take(&self) -> bool { + let Self::Request(scoped) = self else { + return !self.ended(); + }; + let mut issue = scoped.issue.as_ref().expect("unanswered request").lock(); + if issue.peer.is_some() { + return false; + } + issue.taken = true; + true + } + fn reject(self, code: SessionError, reason: &str) { match self { Self::Session(request) => request.reject(code, reason), @@ -985,15 +1000,9 @@ impl Pending { } })); while let Some(event) = state.requests.pop_front() { - if event.ended() { - continue; - } - // From here the application owns the verification, so a withdrawal no longer - // releases what it holds against the request cap. - if let Event::Request(scoped) = &event { - scoped.issue.as_ref().expect("unanswered request").lock().taken = true; + if event.take() { + return Poll::Ready(Some(event)); } - return Poll::Ready(Some(event)); } if state.closed { return Poll::Ready(None); diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index dcd3ad82f4..8939badac7 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -46,10 +46,17 @@ pub(super) fn poll_withdrawn( version: Version, cx: &mut Context<'_>, ) -> Poll<()> { - // Virtual writers on drafts 14-16 are already closed while their control request lives. - if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && stream.writer.poll_closed(cx).is_ready() - { + // 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) { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 22f3845ce6..ba452a39fb 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2464,6 +2464,8 @@ where let order = aggregate.map(|s| s.order).unwrap_or_default(); // Re-sent unchanged on a draft-14 request-token renewal, whose fields are all mandatory. let subscriber_priority = super::priority::to_wire(priority); + // What the SUBSCRIBE carries, which a replacement set before SUBSCRIBE_OK must renew. + let sent_token = self.request_token.peek(); let written = async { stream.writer.varint(ietf::Subscribe::ID).await?; stream @@ -2480,7 +2482,7 @@ where properties_wanted: matches!(target, Target::Request(_)), forward: true, range_filters: false, - authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), + authorization_token: sent_token.clone().map(crate::ietf::token::RequestToken), }) .await } @@ -2752,8 +2754,8 @@ where let demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); - // The request token last presented on this subscription (its initial value). - let mut last_token = self.request_token.peek(); + // The request token last presented on this subscription: the one its SUBSCRIBE carried. + let mut last_token = sent_token; // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement // that arrives while one is in flight is coalesced into `last_token` and sent once // the outstanding one is answered, so the sender never outruns the receiver's @@ -8486,6 +8488,77 @@ mod tests { } } + /// A token replaced while SUBSCRIBE_OK is outstanding still goes out as a renewal: the + /// subscription tracks the token its SUBSCRIBE carried, not the one current at SUBSCRIBE_OK. + #[moq_net_sim::test] + async fn a_token_replaced_before_subscribe_ok_is_renewed() { + for version in [Version::Draft14, Version::Draft18] { + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + // SUBSCRIBE_OK is withheld until the token is replaced. + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); + let peer = session.clone(); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(typed(&first))); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_request_token(token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + // Held for the test so the track never reads as unused (which would cancel it). + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut serving = std::pin::pin!(subscriber.run_subscribe(Path::new("broadcast"), dynamic, request)); + + let mut sent = false; + for _ in 0..200 { + let _ = futures::poll!(serving.as_mut()); + if occurrences(&log, &first) >= 1 { + sent = true; + break; + } + settle().await; + } + assert!(sent, "{version}: the initial token must ride the SUBSCRIBE"); + + token.set(Some(typed(&second))); + peer.push(&subscribe_ok_bytes(version).await); + let mut renewed = false; + for _ in 0..200 { + let _ = futures::poll!(serving.as_mut()); + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!( + renewed, + "{version}: a token replaced before SUBSCRIBE_OK must be renewed" + ); + + drop(subscription); + drop(track); + drop(consumer); + } + } + /// A token-bearing client does not self-censor on its connection grant: the /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the /// token; a token-less client with the same grant rejects it locally, as before. @@ -8777,6 +8850,52 @@ mod tests { assert!(announced, "a valid request token must authorize the announce"); } + /// 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] From 5bf9d6e755204925acb8313985155a386dc4c50b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 01:46:08 -0700 Subject: [PATCH 16/53] fix(net): MAX_REQUEST_UPDATES is an unknown option before draft 19 A peer on drafts 14 through 18 may repeat SETUP option 0x08 like any unknown option; only draft 19+ refuses a duplicate. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/parameters.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index c855824dd4..500395eb8d 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -89,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); } @@ -613,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 [ From 788045412827002633f5fd65aab51bece1e8cb3f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 01:55:48 -0700 Subject: [PATCH 17/53] fix(net): recheck the request token before closing for an out-of-grant announce A token set by another thread after the change check, within the same poll, still stands enforcement down instead of closing the session. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/session.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 1ff433886c..602913ae98 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1448,7 +1448,13 @@ async fn enforce_grant( // The token changed: recheck whether announces now carry it. None => continue, Some(None) => return Ok(()), - Some(Some(path)) => break path, + Some(Some(path)) => { + // Another thread may have set a token after the change check in that same poll. + if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { + return Ok(()); + } + break path; + } } }; tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); From 616118ec7f5597500290a78f540db30e9d06d7a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 02:29:50 -0700 Subject: [PATCH 18/53] fix(net): retract a repeated-token announce before the peer acknowledges An update repeating its AUTHORIZATION TOKEN withdrew the announce only after the REQUEST_ERROR and cancel were acknowledged, so a peer that stopped reading kept it routed. Every withdrawal now releases the route first through one helper. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 74 ++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index ba452a39fb..ddb707527a 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1517,10 +1517,7 @@ where // 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) => { - // Release the route first: the withdrawal waits on the peer. - if std::mem::take(attached) { - self.stop_announce(path.clone(), token_grant.is_some())?; - } + self.release_announce(path, attached, token_grant.is_some())?; if stream.writer.finish().is_ok() { let _ = stream.writer.closed().await; } @@ -1551,11 +1548,8 @@ where } Turn::Failed(err) => return Err(err), Turn::Ended(refusal) => { - // Retract the route before the withdrawal, which waits on the peer: a lapsed - // grant serves nothing more, however slowly the peer reads. - if std::mem::take(attached) { - self.stop_announce(path.clone(), true)?; - } + // A lapsed grant serves nothing more, however slowly the peer reads. + self.release_announce(path, attached, true)?; self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) .await?; return Err(refusal.error); @@ -1575,9 +1569,7 @@ where // 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. - if std::mem::take(attached) { - self.stop_announce(path.clone(), true)?; - } + self.release_announce(path, attached, true)?; self.write_error(stream, rid, &refusal.error, &refusal.reason).await?; self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) .await?; @@ -1626,6 +1618,7 @@ where // refused update does. Err(DecodeError::MalformedAuthToken) => { let err = Error::Decode(DecodeError::MalformedAuthToken); + self.release_announce(path, attached, token_grant.is_some())?; self.write_error(stream, update_id, &err, "repeated AUTHORIZATION TOKEN") .await?; self.cancel_announce(stream, request_id, path, &err, "repeated AUTHORIZATION TOKEN") @@ -1718,6 +1711,15 @@ where } } + /// 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( @@ -9192,6 +9194,54 @@ mod tests { ); } + /// 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").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. From 517fdb33bd113f627652bedcb16fa4b4913f0eea Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 03:19:02 -0700 Subject: [PATCH 19/53] fix(net): read the announce token baseline before the initial set goes out Every advertisement now goes out after the renewal baseline is read, so a replacement set mid-way is always re-presented. Also covers a token replaced while a PUBLISH_NAMESPACE awaits its answer. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 68 ++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index a8ac84de16..e0ffabcc64 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3162,6 +3162,10 @@ where kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; } (ns.permit, ns.epoch) = self.permit_now(); + // The request token presented on these announces, read before the initial set goes + // out: a client replacing it mid-way wakes the loop, which re-presents the new one on + // each live announce. + let mut last_token = self.request_token.peek(); for update in initial { self.apply_update(&mut ns, &prefix, update, true).await?; } @@ -3173,10 +3177,6 @@ where let mut retry_at: Option = None; let mut retry_delay = RETRY_BASE; - // The request token last presented on these announces (their initial value). A client - // replacing it wakes the loop, which re-presents the new one on each live announce. - let mut last_token = self.request_token.peek(); - // Stream updates (origin route (un)announces), bailing if the peer closes // its side first. let res = loop { @@ -12411,6 +12411,66 @@ mod tests { assert!(renewed, "a replaced token must be re-presented on the live announce"); } + /// A token replaced while the initial PUBLISH_NAMESPACE awaits its answer is re-presented + /// once that answer arrives, rather than counted as the token the announce carried. + #[moq_net_sim::test] + async fn a_token_replaced_during_the_initial_announce_is_re_presented() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // The peer holds its answer until the test pushes it. + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(typed(&first))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, &first), + 1, + "the initial token rides the PUBLISH_NAMESPACE" + ); + + token.set(Some(typed(&second))); + let mut answers = publish_namespace_ok(VERSION).await; + answers.extend(publish_namespace_ok(VERSION).await); + session.push(&answers); + let mut renewed = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!(renewed, "a token replaced mid-announce must be re-presented"); + } + /// Setting the same token again is a no-op: no second REQUEST_UPDATE is sent, so the token /// bytes appear once (the initial PUBLISH_NAMESPACE) and no more. #[moq_net_sim::test] From e80f4aa1be4e507711ff3c05a16cdab909bc8774 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 04:08:12 -0700 Subject: [PATCH 20/53] fix(net): admit a request grant as it stands, not as first issued An acceptor that accepted and then revoked, dropped or replaced the grant before the verdict was read had the original grant admitted, attaching a route that only a later poll would retract. The verdict now folds every queued reply and the dropped state in, for initial admission and renewals. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 53 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 999063275c..46461c9ecc 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1256,7 +1256,9 @@ impl RequestVerdict { /// 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`]. + /// 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())); @@ -1267,11 +1269,21 @@ impl RequestVerdict { false => Poll::Ready(()), }) ); - Poll::Ready(match guard.outbox.pop_front() { - Some(Reply::Grant(grant)) => Ok(grant), - Some(Reply::Refuse { code, reason }) => Err(Refusal::new(Error::Session(code), &reason)), + 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()), + (None, _) => Err(Error::Session(SessionError::Unauthorized).into()), }) } @@ -1923,6 +1935,37 @@ mod request_token_tests { ); } + /// 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), subscribe_path(), RequestKind::Subscribe, None); + let issued = requests.next().await.unwrap().request().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", 1), subscribe_path(), RequestKind::Subscribe, None); + drop(requests.next().await.unwrap().request().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", 2), subscribe_path(), RequestKind::Subscribe, None); + let issued = requests.next().await.unwrap().request().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] From 8457c5250ad7f90bb2d61022c8e2bcdf75e5d375 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 10:25:58 -0700 Subject: [PATCH 21/53] feat(auth): request tokens are opt-in through their own queue Request tokens no longer share the session-token queue. `Handle::requests()` answers session tokens exactly as on main, and the new experimental `Handle::scoped()` yields `Scoped` request tokens with their own bound. An application that never takes it, such as moq-relay, refuses every token outside its limit with NOT_SUPPORTED without any code. moq-lite refuses `scoped()` outright, so nothing waits on a queue that can never fill. `set_request_token` takes `Option`, so a client can stop presenting one, and moq-tokio keeps a token only once the live session accepts it. Everything a token-authorized SUBSCRIBE or PUBLISH_NAMESPACE does once admitted, its setup and terminal writes included, now runs inside `auth::until`, which drops it the moment the grant ends. Resolution, SUBSCRIBE_OK, the end-of-track marker, and the announce OK can no longer outlive a revoked or expired grant. The upstream subscription or route is released before the peer is told. Answers are buffered whole before their first await, so a cut-short OK still reaches the wire ahead of PUBLISH_DONE or the FIN. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 2 +- rs/moq-net/benches/auth.rs | 12 +- rs/moq-net/src/auth.rs | 529 +++++++++++-------- rs/moq-net/src/ietf/auth.rs | 2 +- rs/moq-net/src/ietf/publisher.rs | 759 ++++++++++++++++------------ rs/moq-net/src/ietf/subscriber.rs | 385 ++++++++------ rs/moq-net/src/lite/publisher.rs | 5 +- rs/moq-net/src/server.rs | 18 +- rs/moq-net/tests/auth.rs | 136 +++-- rs/moq-relay/src/auth/refresh.rs | 11 +- rs/moq-tokio/src/connection.rs | 21 +- rs/moq-tokio/src/connection/auth.rs | 88 +++- 12 files changed, 1177 insertions(+), 791 deletions(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 9a4a6fde0f..64a8ac74bb 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** 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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. An update renews it, 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. +- **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index eea18c05c1..044c88254a 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -43,7 +43,7 @@ async fn pair(version: Version) -> Pair { (session, requests) }; let (client, (server, mut requests)) = futures::join!(client, server); - let initial = session(requests.next().await).accept(auth::Grant::all()); + let initial = requests.next().await.unwrap().accept(auth::Grant::all()); Pair { client, _server: server, @@ -59,7 +59,7 @@ impl Pair { let accept = async { let mut issued = Vec::with_capacity(count); for _ in 0..count { - issued.push(session(self.requests.next().await).accept(auth::Grant::all())); + issued.push(self.requests.next().await.unwrap().accept(auth::Grant::all())); } issued }; @@ -100,11 +100,3 @@ fn admission(c: &mut Criterion) { criterion_group!(benches, admission); criterion_main!(benches); - -/// The next token, which these sessions only ever present for the whole session. -fn session(event: Option) -> auth::Request { - match event { - Some(auth::Event::Session(request)) => request, - _ => panic!("expected a session token"), - } -} diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 46461c9ecc..e962fdf3b3 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -137,6 +137,8 @@ pub(crate) struct State { /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, + /// The application's queue of the peer's request tokens, once it took [`Handle::scoped`]. + verifier: Option>, } /// One presented token. @@ -161,7 +163,7 @@ enum Acceptor { #[default] Undecided, /// The app answers every token. - App(Pending), + App(Pending), /// The session answers from its own origin handles. Default, } @@ -327,27 +329,36 @@ impl Handle { } } - /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own requests (MoQ - /// request-token), as distinct from a connection credential, which [`add`](Self::add) - /// presents for the whole session. + /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own SUBSCRIBE and + /// PUBLISH_NAMESPACE requests (MoQ request-token), or stop presenting one with `None`. + /// Unlike [`add`](Self::add), which presents a credential for the whole session, the + /// peer verifies this one per request. Experimental. /// /// Set it before running the session's driver to present it on the first request. /// Replacing it later re-presents the new token on every live request as a /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value - /// again sends nothing. A draft 14 or 15 advertisement has no update, so it keeps the - /// token it was offered with. Independent of the MoQ Auth extension. + /// again sends nothing. Only drafts 14 and 15 cannot renew an advertisement, which has + /// no update there, so it keeps the token it was offered with. `None` sends nothing + /// either: live requests keep the token they last presented, and later ones carry none. + /// Independent of the MoQ Auth extension. /// - /// A moq-lite session has no request token to carry it, so it refuses with - /// [`Error::Unsupported`] rather than drop it. - pub fn set_request_token(&self, token: crate::setup::Token) -> Result<()> { - if self.state.read().lite { + /// Once a token is set, this side stops closing the session over a broadcast its own + /// grant does not cover, since each announce carries the token for the peer to verify. + /// + /// # Errors + /// + /// [`Error::Unsupported`] for a token on a moq-lite session, whose requests cannot carry + /// one, rather than drop it. + pub fn set_request_token(&self, token: Option) -> Result<()> { + if token.is_some() && self.state.read().lite { return Err(Error::Unsupported); } - self.request_token.set(Some(token)); + self.request_token.set(token); Ok(()) } - /// The session speaks moq-lite, so [`set_request_token`](Self::set_request_token) refuses. + /// The session speaks moq-lite, so [`set_request_token`](Self::set_request_token) and + /// [`scoped`](Self::scoped) refuse. pub(crate) fn lite(&self) { self.state.lock().lite = true; } @@ -414,11 +425,8 @@ impl Handle { }) } - /// Answer every token the peer presents, instead of the default acceptor. - /// - /// Session tokens arrive as [`Event::Session`]. An `AUTHORIZATION TOKEN` on a SUBSCRIBE - /// or PUBLISH_NAMESPACE that [`authorize`](Self::authorize) does not cover arrives as - /// [`Event::Request`] and decides that request alone. Experimental. + /// Answer every session token the peer presents, instead of the default acceptor. + /// A token on one of the peer's requests goes to [`scoped`](Self::scoped) instead. /// /// 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 @@ -434,27 +442,60 @@ impl Handle { /// /// [`Error::Duplicate`] if the requests were already taken, or the driver /// already started answering them itself. - pub fn requests(&self) -> Result { + pub fn requests(&self) -> Result> { let mut state = self.state.lock(); if !matches!(state.acceptor, Acceptor::Undecided) { return Err(Error::Duplicate); } let queue = Pending::default(); - // A request-borne token rides the request message itself, not the AUTH stream, so the - // acceptor answers request tokens even on a session that never negotiated the MoQ Auth - // extension. The queue therefore stays live regardless of `supported`; only session - // token presentation ([`add`](Self::add)) stays [`Error::Unsupported`] without it. + // A version without AUTH never receives a token, so its requests end with + // the session like any other. + if !state.supported { + queue.close(); + } state.acceptor = Acceptor::App(queue.clone()); Ok(Requests { queue }) } - /// Ask the acceptor to verify a token that rode on one request. + /// Verify the `AUTHORIZATION TOKEN` on each of the peer's SUBSCRIBE and PUBLISH_NAMESPACE + /// requests that [`authorize`](Self::authorize) does not cover. Experimental. + /// + /// Each token decides its request alone, with a grant that never joins the session's. + /// Without this, such a request is refused as unsupported, and a session never + /// authorized admits every request without reading its token. Take it before running the + /// session's driver, so no request is admitted before it. Independent of the MoQ Auth + /// extension, since the token rides the request itself. + /// + /// At most 128 unanswered tokens queue, not counting withdrawn requests; more are + /// refused with [`SessionError::TooManyRequests`]. /// - /// The token reaches the application as an [`Event::Request`]. The grant it answers covers - /// only this request, never joins the session union, and ends when the returned - /// [`RequestVerdict`] is dropped. With no [`requests`](Self::requests) consumer the token - /// cannot be verified, so the verdict is [`Error::Unsupported`]. `hold` is the request's - /// slot, kept while the application verifies the token. + /// # Errors + /// + /// [`Error::Duplicate`] if already taken, or [`Error::Unsupported`] on moq-lite, whose + /// requests carry no token. + pub fn scoped(&self) -> Result> { + let mut state = self.state.lock(); + if state.lite { + return Err(Error::Unsupported); + } + if state.verifier.is_some() { + return Err(Error::Duplicate); + } + let queue = Pending::default(); + if state.closed.is_some() { + queue.close(); + } + state.verifier = Some(queue.clone()); + Ok(Requests { queue }) + } + + /// Ask the application to verify a token that rode on one request. + /// + /// The token reaches it through [`scoped`](Self::scoped). The grant it answers covers only + /// this request, never joins the session union, and ends when the returned + /// [`RequestVerdict`] is dropped. Without a verifier the token cannot be verified, so the + /// verdict is [`Error::Unsupported`]. `hold` is the request's slot, kept while the + /// application verifies the token. pub(crate) fn verify_request( &self, token: crate::setup::Token, @@ -462,7 +503,7 @@ impl Handle { kind: RequestKind, hold: Option, ) -> RequestVerdict { - let Some(queue) = self.acceptor() else { + let Some(queue) = self.state.read().verifier.clone() else { return RequestVerdict { issue: None }; }; let issue = kio::Shared::new(Issue { @@ -470,18 +511,18 @@ impl Handle { ..Default::default() }); // A closed or full queue refuses the token. - queue.push(Event::Request(Scoped { + queue.push(Scoped { token, path, kind, issue: Some(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. - pub(crate) fn acceptor(&self) -> Option { + pub(crate) fn acceptor(&self) -> Option> { let mut state = self.state.lock(); match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -589,7 +630,7 @@ impl Handle { /// session's origin handles still bound it, so it never grants more than they allow. /// /// A request outside it that carries its own token is decided by that token through - /// [`requests`](Self::requests), or refused as unsupported when nothing answers them. + /// [`scoped`](Self::scoped), or refused as unsupported when nothing verifies them. /// /// A narrower grant ends what falls outside at once, with [`Error::Unauthorized`] /// where it has a reader: announcements to the peer retract, its new requests are @@ -652,14 +693,11 @@ impl Handle { /// token, or the request is refused as unsupported without a verifier. A session never /// limited asks only an application that verifies request tokens, and admits otherwise. pub(crate) fn token_decides(&self, direction: Direction, path: &str) -> bool { - let limited = { - let state = self.state.read(); - let (_, limit) = state.parts(direction); - limit.map(|limit| !limit.matches(path)) - }; - match limited { - Some(outside) => outside, - None => self.acceptor().is_some(), + let state = self.state.read(); + let (_, limit) = state.parts(direction); + match limit { + Some(limit) => !limit.matches(path), + None => state.verifier.is_some(), } } @@ -691,9 +729,10 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - // The request queue stays live: a request-borne token does not ride the AUTH stream, so - // the acceptor keeps answering request tokens without the extension. Only the session - // tokens above end as unsupported. + // Request tokens ride the requests themselves, so their queue stays open. + if let Acceptor::App(queue) = &state.acceptor { + queue.close(); + } } /// End the session: fail every pending token, end every watch, and close the @@ -714,6 +753,9 @@ impl Handle { if let Acceptor::App(queue) = &state.acceptor { queue.close(); } + if let Some(queue) = &state.verifier { + queue.close(); + } } } @@ -864,115 +906,113 @@ impl Watch { } } -/// The tokens the peer presents, for an application that answers them itself. +/// The tokens the peer presents, for an application that answers them itself: session +/// tokens ([`Request`]) from [`Handle::requests`], and tokens on single requests +/// ([`Scoped`]) from [`Handle::scoped`]. /// -/// Returned by [`Handle::requests`]. Dropping it refuses every later token. -pub struct Requests { - queue: Pending, +/// Dropping it refuses every later token. +pub struct Requests { + queue: Pending, } -impl Requests { - /// Poll for the next token, or `None` once the session ends. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { +impl Requests { + /// Poll for the next session token, or `None` once the session ends. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } - /// Wait for the next token the peer presents, or `None` once the session ends. - pub async fn next(&mut self) -> Option { + /// Wait for the next session token the peer presents, or `None` once the session ends. + pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await } } -impl Drop for Requests { +impl Requests { + /// Poll for the next request token, or `None` once the session ends. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + self.queue.poll_next(waiter) + } + + /// Wait for the next request token the peer presents, or `None` once the session ends. + pub async fn next(&mut self) -> Option { + kio::wait(|waiter| self.poll_next(waiter)).await + } +} + +impl Drop for Requests { fn drop(&mut self) { self.queue.close(); } } -/// A token the peer presented, from [`Requests::next`]. -/// -/// A session token and a request token answer through different handles, so a handler -/// written for session tokens decides what to do with a request token instead of granting -/// it the session by accident. -#[non_exhaustive] -pub enum Event { - /// A token for the whole session, answered with a grant the peer is told. - Session(Request), - /// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, answered with a grant only that - /// request holds. Experimental. - Request(Scoped), -} - -impl Event { - fn ended(&self) -> bool { - let issue = match self { - Self::Session(request) => &request.issue, - Self::Request(scoped) => &scoped.issue, - }; - issue.as_ref().expect("unanswered request").read().peer.is_some() - } +/// A token waiting in a [`Pending`] queue for the application's answer. +pub(crate) trait Queued { + /// What the session and the application share about it. + fn issue(&self) -> &kio::Shared; + /// Refuse it without asking the application. + fn refuse(self, code: SessionError, reason: &str); +} - /// Hand the event to the application, or `false` when its request already ended. A - /// request token is marked taken under the same lock its withdrawal checks, so from here - /// the application owns its slot until it answers. - fn take(&self) -> bool { - let Self::Request(scoped) = self else { - return !self.ended(); - }; - let mut issue = scoped.issue.as_ref().expect("unanswered request").lock(); - if issue.peer.is_some() { - return false; - } - issue.taken = true; - true +impl Queued for Request { + fn issue(&self) -> &kio::Shared { + self.issue.as_ref().expect("unanswered request") } - fn reject(self, code: SessionError, reason: &str) { - match self { - Self::Session(request) => request.reject(code, reason), - Self::Request(scoped) => scoped.reject(code, reason), - } + fn refuse(self, code: SessionError, reason: &str) { + self.reject(code, reason) } } -#[cfg(test)] -impl Event { - pub(crate) fn session(self) -> Request { - match self { - Self::Session(request) => request, - Self::Request(_) => panic!("expected a session token"), - } +impl Queued for Scoped { + fn issue(&self) -> &kio::Shared { + self.issue.as_ref().expect("unanswered request") } - pub(crate) fn request(self) -> Scoped { - match self { - Self::Request(scoped) => scoped, - Self::Session(_) => panic!("expected a request token"), - } + fn refuse(self, code: SessionError, reason: &str) { + self.reject(code, reason) } } /// Pending application decisions, bounded independently of transport stream credit. -#[derive(Clone, Default)] -pub(crate) struct Pending { - state: kio::Shared, +pub(crate) struct Pending { + state: kio::Shared>, } -#[derive(Default)] -struct PendingState { - requests: VecDeque, +impl Clone for Pending { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + } + } +} + +impl Default for Pending { + fn default() -> Self { + Self { + state: kio::Shared::new(PendingState { + requests: VecDeque::new(), + closed: false, + }), + } + } +} + +struct PendingState { + requests: VecDeque, closed: bool, } -impl Pending { +impl Pending { fn close(&self) { let mut state = self.state.lock(); state.closed = true; state.requests.clear(); } +} +impl Pending { /// Queue the token for the application, or refuse it when closed or full. - pub(crate) fn push(&self, event: Event) { + pub(crate) fn push(&self, request: T) { let mut state = self.state.lock(); // Dropping the request refuses it as unanswered. if state.closed { @@ -980,17 +1020,17 @@ impl Pending { } // A withdrawn stream must not occupy a slot while the verifier is busy. if state.requests.len() >= 128 { - state.requests.retain(|event| !event.ended()); + state.requests.retain(|request| request.issue().read().peer.is_none()); } if state.requests.len() >= 128 { drop(state); - event.reject(SessionError::TooManyRequests, "too many pending tokens"); + request.refuse(SessionError::TooManyRequests, "too many pending tokens"); return; } - state.requests.push_back(event); + state.requests.push_back(request); } - fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { loop { let mut state = std::task::ready!(self.state.poll(waiter, |state| { if state.closed || !state.requests.is_empty() { @@ -999,9 +1039,14 @@ impl Pending { Poll::Pending } })); - while let Some(event) = state.requests.pop_front() { - if event.take() { - return Poll::Ready(Some(event)); + while let Some(request) = state.requests.pop_front() { + // 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)); } } if state.closed { @@ -1336,8 +1381,12 @@ impl Drop for RequestVerdict { /// 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. -pub(crate) struct RequestGrant { +#[derive(Clone)] +pub(crate) struct RequestGrant(std::sync::Arc>); + +struct Held { verdict: RequestVerdict, grant: Grant, deadline: crate::time::Deadline, @@ -1367,7 +1416,7 @@ impl RequestGrant { } let mut deadline = crate::time::Deadline::new(clock); deadline.set(grant.expires); - Ok(Self { + Ok(Self(std::sync::Arc::new(std::sync::Mutex::new(Held { verdict, grant, deadline, @@ -1375,28 +1424,28 @@ impl RequestGrant { kind, hold, clock: clock.clone(), - }) + })))) } - /// Whether `grant` covers the request this guard holds. - fn covers(&self, grant: &Grant) -> bool { - self.kind.covers(grant, self.path.as_str()) + 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.grant + 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.hold.clone() + 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 { - handle.verify_request(token, self.path.clone(), self.kind, self.hold.clone()) + let held = self.held(); + handle.verify_request(token, held.path.clone(), held.kind, held.hold.clone()) } /// Apply a renewal's verdict, or return the refusal that ends the request. @@ -1406,17 +1455,18 @@ impl RequestGrant { /// 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( - &mut self, + &self, verdict: RequestVerdict, res: std::result::Result, ) -> std::result::Result<(), Refusal> { let grant = res?; - if !live(&self.clock, self.kind, self.path.as_str(), &grant) { + 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")); } - self.verdict = verdict; - self.deadline.set(grant.expires); - self.grant = grant; + held.verdict = verdict; + held.deadline.set(grant.expires); + held.grant = grant; Ok(()) } @@ -1425,18 +1475,19 @@ impl RequestGrant { /// 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(&mut self, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_ended(&self, waiter: &kio::Waiter) -> Poll { + let mut held = self.held(); loop { - if self.deadline.poll(waiter).is_ready() { + if held.deadline.poll(waiter).is_ready() { return Poll::Ready(Refusal::new(Error::Unauthorized, "request token expired")); } - match self.verdict.poll_reply(waiter) { + match held.verdict.poll_reply(waiter) { Poll::Ready(Reply::Grant(grant)) => { - if !self.covers(&grant) { + if !held.kind.covers(&grant, held.path.as_str()) { return Poll::Ready(Refusal::new(Error::Unauthorized, "request token no longer granted")); } - self.deadline.set(grant.expires); - self.grant = grant; + held.deadline.set(grant.expires); + held.grant = grant; // Re-poll: the new deadline may already have lapsed, or another reply may // be waiting. continue; @@ -1448,6 +1499,42 @@ impl RequestGrant { } } } + + /// 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. +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 @@ -1735,10 +1822,10 @@ mod tests { let _requests = handle.requests().unwrap(); let queue = handle.acceptor().unwrap(); for _ in 0..128 { - queue.push(Event::Session(Request::new(Bytes::new(), Default::default()))); + queue.push(Request::new(Bytes::new(), Default::default())); } let overflow = kio::Shared::::default(); - queue.push(Event::Session(Request::new(Bytes::new(), overflow.clone()))); + queue.push(Request::new(Bytes::new(), overflow.clone())); let overflow = overflow.lock(); assert!(overflow.done); assert!(matches!( @@ -1750,6 +1837,63 @@ mod tests { )); } + /// Request tokens queue apart from session tokens, so a backlog of one never refuses the + /// other. + #[test] + fn request_tokens_are_bounded_apart_from_session_tokens() { + let handle = Handle::new(true); + let _requests = handle.requests().unwrap(); + let mut scoped = handle.scoped().unwrap(); + let queue = handle.acceptor().unwrap(); + for _ in 0..128 { + queue.push(Request::new(Bytes::new(), Default::default())); + } + let token = crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + }; + let path = crate::Path::new("room").to_owned(); + let _verdict = handle.verify_request(token, path, RequestKind::Subscribe, None); + assert!( + matches!(scoped.poll_next(&kio::Waiter::noop()), Poll::Ready(Some(_))), + "a full session queue refused a request token" + ); + } + + /// Only an application that takes [`Handle::scoped`] verifies request tokens, and only once. + #[test] + fn request_tokens_are_opt_in() { + let handle = Handle::new(true); + let _requests = handle.requests().unwrap(); + let path = || crate::Path::new("room").to_owned(); + let token = || crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + }; + assert!( + !handle.token_decides(Direction::Publish, "room"), + "a session never limited and with no verifier ignores the token" + ); + let verdict = handle.verify_request(token(), path(), RequestKind::Subscribe, None); + assert!(matches!( + verdict.poll_grant(&kio::Waiter::noop()), + Poll::Ready(Err(Refusal { + error: Error::Unsupported, + .. + })) + )); + + let _scoped = handle.scoped().unwrap(); + assert!(matches!(handle.scoped(), Err(Error::Duplicate))); + assert!(handle.token_decides(Direction::Publish, "room")); + + let lite = Handle::new(false); + lite.lite(); + assert!(matches!(lite.scoped(), Err(Error::Unsupported))); + assert!(matches!(lite.set_request_token(Some(token())), Err(Error::Unsupported))); + lite.set_request_token(None).expect("clearing is always allowed"); + } + #[test] fn withdrawn_tokens_do_not_occupy_pending_slots() { let handle = Handle::new(true); @@ -1757,20 +1901,14 @@ mod tests { let queue = handle.acceptor().unwrap(); let expired = kio::Shared::::default(); for _ in 0..128 { - queue.push(Event::Session(Request::new( - Bytes::from_static(b"withdrawn"), - expired.clone(), - ))); + queue.push(Request::new(Bytes::from_static(b"withdrawn"), expired.clone())); } expired.lock().peer = Some(Error::Cancel); - queue.push(Event::Session(Request::new( - Bytes::from_static(b"live"), - Default::default(), - ))); + queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; - assert_eq!(request.session().token().as_ref(), b"live"); + assert_eq!(request.token().as_ref(), b"live"); assert!(requests.poll_next(&kio::Waiter::noop()).is_pending()); } @@ -1872,10 +2010,10 @@ mod request_token_tests { #[moq_net_sim::test] async fn a_request_token_reaches_the_acceptor_with_its_context() { let handle = Handle::new(true); - let mut requests = handle.requests().expect("take the requests"); + let mut requests = handle.scoped().expect("take the requests"); let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe, None); - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); assert_eq!(request.token(), &token(b"jwt", 7)); assert_eq!(request.path(), &subscribe_path()); assert_eq!(request.kind(), RequestKind::Subscribe); @@ -1886,17 +2024,17 @@ mod request_token_tests { } /// A request-borne token is verified even without the MoQ Auth extension: it rides the - /// request message, not the AUTH stream, so `requests()` hands a live queue on a session + /// request message, not the AUTH stream, so `scoped()` hands a live queue on a session /// whose `supported` is false and the acceptor admits it. This is the shape a standard /// moq-transport peer (an encoder or CDN) presents when it does not negotiate the moq-dev /// 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"); + let mut requests = handle.scoped().expect("take the requests"); for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind, None); - let request = requests.next().await.expect("a request reaches the acceptor").request(); + let request = requests.next().await.expect("a request reaches the acceptor"); assert_eq!(request.kind(), kind); let _issued = request.accept(Grant::all()); assert!( @@ -1906,7 +2044,8 @@ mod request_token_tests { } } - /// 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. + /// With no `scoped()` 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); @@ -1920,14 +2059,9 @@ mod request_token_tests { #[moq_net_sim::test] async fn a_refused_request_token_is_unauthorized() { let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - requests - .next() - .await - .unwrap() - .request() - .reject(SessionError::Unauthorized, "no"); + requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); assert!( matches!(err.error, Error::Session(SessionError::Unauthorized)), @@ -1940,16 +2074,16 @@ mod request_token_tests { #[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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().request().accept(Grant::all()); + 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", 1), subscribe_path(), RequestKind::Subscribe, None); - drop(requests.next().await.unwrap().request().accept(Grant::all())); + 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)), @@ -1957,7 +2091,7 @@ mod request_token_tests { ); let verdict = handle.verify_request(token(b"jwt", 2), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().request().accept(Grant::all()); + let issued = requests.next().await.unwrap().accept(Grant::all()); issued.update(Grant::default()); let grant = verdict.grant().await.expect("still granted"); assert!( @@ -1971,9 +2105,9 @@ mod request_token_tests { #[moq_net_sim::test] async fn dropping_the_verdict_ends_the_grant() { let handle = Handle::new(true); - let mut requests = handle.requests().unwrap(); + let mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().request().accept(Grant::all()); + let issued = requests.next().await.unwrap().accept(Grant::all()); verdict.grant().await.expect("granted"); drop(verdict); let err = issued.closed().await; @@ -1985,9 +2119,9 @@ mod request_token_tests { #[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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let _issued = requests.next().await.unwrap().request().accept(Grant::all()); + 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. @@ -2009,11 +2143,11 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let grant = grant_in(&runtime, 60); let expires = grant.expires; - let _issued = requests.next().await.unwrap().request().accept(grant); + let _issued = requests.next().await.unwrap().accept(grant); let answered = verdict.grant().await.unwrap(); let request_grant = RequestGrant::new( &runtime, @@ -2034,14 +2168,14 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let expired = || Grant { expires: Some(runtime.now()), ..grant_in(&runtime, 0) }; let verdict = handle.verify_request(token(b"old", 0), subscribe_path(), RequestKind::Subscribe, None); - let _issued = requests.next().await.unwrap().request().accept(expired()); + let _issued = requests.next().await.unwrap().accept(expired()); let answered = verdict.grant().await; let refused = RequestGrant::new( &runtime, @@ -2057,9 +2191,9 @@ mod request_token_tests { ); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let _issued = requests.next().await.unwrap().request().accept(grant_in(&runtime, 60)); + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await; - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, answered, @@ -2069,7 +2203,7 @@ mod request_token_tests { ) .unwrap(); let renewal = handle.verify_request(token(b"old2", 0), subscribe_path(), RequestKind::Subscribe, None); - let _issued = requests.next().await.unwrap().request().accept(expired()); + let _issued = requests.next().await.unwrap().accept(expired()); let renewed = renewal.grant().await; assert!( request_grant.renew(renewal, renewed).is_err(), @@ -2082,14 +2216,14 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().request().accept(first); + let _issued = requests.next().await.unwrap().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, Ok(answered), @@ -2103,7 +2237,7 @@ mod request_token_tests { let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe, None); let second = grant_in(&runtime, 600); let second_expires = second.expires; - let _issued2 = requests.next().await.unwrap().request().accept(second); + let _issued2 = requests.next().await.unwrap().accept(second); let renewed = renewal.grant().await; assert!( request_grant.renew(renewal, renewed).is_ok(), @@ -2120,12 +2254,12 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); // Held for the request's life, so only the deadline (not a drop) ends it. - let _issued = requests.next().await.unwrap().request().accept(grant_in(&runtime, 60)); + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, Ok(answered), @@ -2148,13 +2282,13 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); let first = grant_in(&runtime, 60); let first_expires = first.expires; - let _issued = requests.next().await.unwrap().request().accept(first); + let _issued = requests.next().await.unwrap().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, Ok(answered), @@ -2170,7 +2304,6 @@ mod request_token_tests { .next() .await .unwrap() - .request() .reject(SessionError::Unauthorized, "bad token"); let refused = renewal.grant().await; assert!( @@ -2195,11 +2328,11 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); - let issued = requests.next().await.unwrap().request().accept(Grant::all()); + let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, Ok(answered), @@ -2251,12 +2384,12 @@ mod request_token_tests { 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 mut requests = handle.scoped().unwrap(); let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); // A grant that never expires, so only the revoke can end the request. - let issued = requests.next().await.unwrap().request().accept(Grant::all()); + let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new( + let request_grant = RequestGrant::new( &runtime, verdict, Ok(answered), diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 14c2b966ae..f24452f7c3 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -433,7 +433,7 @@ impl Serve { let mut default = None; match self.handle.acceptor() { Some(requests) => { - requests.push(crate::auth::Event::Session(Request::new(msg.token, issue.clone()))); + requests.push(Request::new(msg.token, issue.clone())); } // Only the connection's own credential has a default answer; a token needs // someone to verify it. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e0ffabcc64..9f1537eb89 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -345,6 +345,14 @@ enum Served { 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) -> (ietf::PublishDoneStatus, &'static str) { @@ -861,211 +869,241 @@ where }; let _slot = slot; - // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in - // the model, through the tagged `origin::Consumer` the broadcast resolves from. + // 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)), + }; - // 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 track = match broadcast.track(&msg.track_name) { + Ok(track) => track, + 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 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 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; + // 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)); } - } - }; - // 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)); - } + // 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?; - // 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)); + // 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; + } + }; + let served = self + .run_subscription( + &mut stream, + &mut track_serve, + &mut request_finished, + fill, + request_grant.clone(), + ) + .await; - // 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()); + let completed = served.is_some(); + let served = served.unwrap_or(Served::Track(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 + && 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"); } - 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; - } - }; - // 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, - request_grant, - msg.track_namespace.to_owned(), - ); - 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); + // 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(Served::Track(Err(Error::Unauthorized)))); + return Poll::Ready(Error::Unauthorized.into()); } Poll::Pending }) .await }; - - let completed = served.is_some(); - let served = served.unwrap_or(Served::Track(Ok(()))); + // A FETCH joining this subscription ends with it, before the peer reads a word. + drop(_join); + + 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; + } + 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(); let res = match served { Served::Track(res) => res, Served::UpdateFailed(err) => Err(err), }; - // 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"); - } - } - - // 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(); - // Send PublishDone let _ = stream.writer.varint(ietf::PublishDone::ID).await; let _ = stream @@ -1094,8 +1132,7 @@ where serve: &mut TrackServe, finished: &mut bool, fill: impl std::future::Future, - mut request_grant: Option, - namespace: crate::PathOwned, + request_grant: Option, ) -> Option { let mut session = self.session.clone(); let mut fill = std::pin::pin!(fill); @@ -1104,8 +1141,8 @@ where // 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 and the old grant's deadline (never a bare await), so media keeps - // flowing and the old deadline still fires when the acceptor is slow. While a verdict is + // 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 @@ -1118,8 +1155,6 @@ where Served(Result<(), Error>), // The peer cancelled, or the session ended. Cancelled, - // The request grant ended the subscription early. - Ended(Error), // A REQUEST_UPDATE, already decoded. Update(super::request_stream::Update), // A pending renewal's verdict resolved, for the update with this request id. @@ -1148,13 +1183,6 @@ where { return Poll::Ready(Turn::Cancelled); } - if let Some(rg) = request_grant.as_mut() - && let Poll::Ready(refusal) = rg.poll_ended(waiter) - { - // PUBLISH_DONE carries a fixed reason per status, so only the code reaches the peer. - tracing::info!(broadcast = %namespace, err = %refusal.error, reason = %refusal.reason, "request token grant ended"); - return Poll::Ready(Turn::Ended(refusal.error)); - } if let Poll::Ready(renewed) = renewal.poll(waiter) { return Poll::Ready(Turn::Renewal(renewed)); } @@ -1230,14 +1258,13 @@ where } Turn::Served(res) => return Some(Served::Track(res)), Turn::Cancelled => return None, - Turn::Ended(err) => return Some(Served::Track(Err(err))), Turn::Renewal(request_update::Verdict { verdict, grant: res, request_id: rid, }) => { // Only a token-authorized request starts a renewal. - let rg = request_grant.as_mut().expect("a renewal without a request grant"); + 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 { @@ -1341,7 +1368,7 @@ where writer: &mut Writer, request_id: RequestId, ) -> Result<(), Error> { - writer.varint(ietf::RequestOk::ID).await?; + writer.buffer_varint(ietf::RequestOk::ID)?; writer .encode(&ietf::RequestOk { request_id: matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id), @@ -1383,7 +1410,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, @@ -1393,7 +1420,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), @@ -1404,7 +1431,7 @@ where .await?; } _ => { - writer.varint(ietf::RequestError::ID).await?; + writer.buffer_varint(ietf::RequestError::ID)?; writer .encode(&ietf::RequestError { request_id: None, @@ -5074,14 +5101,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 {}, - None, - crate::Path::new("room").to_owned(), - )); + let mut run = + std::pin::pin!( + h.publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None,) + ); assert_eq!( futures::poll!(run.as_mut()).is_ready(), super::super::request_stream::fin_cancels(version), @@ -5123,14 +5147,9 @@ mod serve_tests { None, ); let mut finished = false; - let run = h.publisher.run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ); + let run = h + .publisher + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None); assert!(futures::poll!(std::pin::pin!(run)).is_ready(), "{version}"); } } @@ -5213,14 +5232,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 {}, - None, - crate::Path::new("room").to_owned(), - )); + 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); @@ -5248,14 +5264,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 {}, - None, - crate::Path::new("room").to_owned() - )); + 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(); @@ -5292,14 +5305,7 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; assert!( matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), @@ -5526,14 +5532,7 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ) + .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:?}"); @@ -5667,14 +5666,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 {}, - None, - crate::Path::new("room").to_owned(), - )); + 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(), "{version}: update ended it"); assert_eq!(h.track.subscription().unwrap().priority, 245, "{version}"); @@ -5715,14 +5711,7 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; assert!( matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), @@ -5811,14 +5800,7 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; assert!( matches!(served, Some(Served::Track(Err(_)) | Served::UpdateFailed(_))), @@ -5890,14 +5872,7 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription( - &mut stream, - &mut serving, - &mut finished, - async {}, - None, - crate::Path::new("room").to_owned(), - ) + .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) .await; assert!( matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), @@ -8231,7 +8206,7 @@ mod serve_tests { 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(); + let mut requests = auth.scoped().unwrap(); // A limit of nothing, so only the token admits the subscription. auth.authorize(&crate::auth::Grant::default()); let publisher = Publisher::new( @@ -8247,7 +8222,7 @@ mod serve_tests { 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").request() }; + 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:?}"), @@ -8306,6 +8281,150 @@ mod serve_tests { } } + /// 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.scoped().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] @@ -8703,9 +8822,13 @@ mod serve_tests { /// 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) { + 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 requests = auth.scoped().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()); @@ -9011,10 +9134,10 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s; the renewal never expires. - let first = requests.next().await.unwrap().request(); + 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().request(); + 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 @@ -9107,10 +9230,10 @@ mod serve_tests { let release = release.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.unwrap().request(); + 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().request(); + let renewal = requests.next().await.unwrap(); release.notified().await; if accept { held.push(renewal.accept(grant_all_expiring(&rt, None))); @@ -9305,16 +9428,16 @@ mod serve_tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.unwrap().request(); + 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().request(); + 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().request(); + let renewal = requests.next().await.unwrap(); held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } @@ -9372,7 +9495,7 @@ mod serve_tests { // Accept the initial token, then never answer a renewal, so the buffer only grows. let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9424,7 +9547,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9469,7 +9592,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9514,7 +9637,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9583,9 +9706,9 @@ mod serve_tests { let acceptor = { let release = release.clone(); async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); - let renewal = requests.next().await.unwrap().request(); + let renewal = requests.next().await.unwrap(); release.notified().await; renewal.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -9647,7 +9770,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9702,7 +9825,7 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.unwrap().request(); + let first = requests.next().await.unwrap(); let _held = first.accept(grant_all_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9742,10 +9865,10 @@ mod serve_tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request").request(); + 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").request(); + let _never_answered = requests.next().await.expect("a renewal"); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9807,9 +9930,9 @@ mod serve_tests { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").request(); + 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").request(); + let _never_answered = requests.next().await.expect("a renewal"); std::future::pending::<()>().await }; let mut acceptor = std::pin::pin!(acceptor); @@ -9853,10 +9976,10 @@ mod serve_tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.unwrap().request(); + 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().request(); + let renewal = requests.next().await.unwrap(); renewal.reject(crate::SessionError::Unauthorized, "no"); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -9929,11 +10052,11 @@ mod serve_tests { async move { let mut held = Vec::new(); // The first grant lapses in 60s. - let first = requests.next().await.expect("a request").request(); + 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").request(); + let _slow = requests.next().await.expect("a renewal"); popped.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -9978,7 +10101,7 @@ mod serve_tests { 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(); + let mut requests = auth.scoped().unwrap(); auth.authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); @@ -9994,7 +10117,7 @@ mod serve_tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); consulted.fetch_add(1, Ordering::Relaxed); held.push(request.accept(crate::auth::Grant::all())); } @@ -10029,7 +10152,7 @@ mod serve_tests { ) -> (crate::auth::Handle, Serve, Serving) { const VERSION: Version = Version::Draft18; let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let mut requests = auth.scoped().unwrap(); if let Some(limit) = &limit { auth.authorize(limit); } @@ -10044,7 +10167,7 @@ mod serve_tests { let serving: Serving = Box::pin(async move { let acceptor = async move { let mut held = Vec::new(); - while let Some(crate::auth::Event::Request(request)) = requests.next().await { + while let Some(request) = requests.next().await { held.push(request.accept(grant.clone())); } std::future::pending::>().await @@ -10119,7 +10242,7 @@ mod serve_tests { 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 mut requests = auth.scoped().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()); @@ -10129,7 +10252,7 @@ mod serve_tests { let acceptor = { let consulted = consulted.clone(); async move { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); consulted.fetch_add(1, Ordering::Relaxed); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await @@ -10221,7 +10344,7 @@ mod serve_tests { // Withdrawn before the application looked. let auth = crate::auth::Handle::new(false); - let _requests = auth.requests().unwrap(); + let _requests = auth.scoped().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(); @@ -10243,7 +10366,7 @@ mod serve_tests { // Withdrawn after the application took it. let auth = crate::auth::Handle::new(false); - let mut requests = auth.requests().unwrap(); + let mut requests = auth.scoped().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(); @@ -10255,7 +10378,7 @@ mod serve_tests { if taken.is_none() && let Poll::Ready(Some(event)) = requests.poll_next(&kio::Waiter::noop()) { - taken = Some(event.request()); + taken = Some(event); } settle().await; } @@ -10283,7 +10406,7 @@ mod serve_tests { 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 mut requests = auth.scoped().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index ddb707527a..6ebce47ac2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -577,6 +577,19 @@ enum Admit { 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. struct Advertised { /// The route it describes, with this link's price already charged. The prefix @@ -1360,46 +1373,74 @@ where }; let token = token_grant.is_some(); - match self.start_announce(path.clone(), advert, admit) { - 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, token); - return Err(err); - } - } - Err(err) => { - self.write_error(&mut stream, request_id, &err, &err.to_string()) - .await?; - let _ = stream.writer.close().await; - return Ok(()); - } - } - - // 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( + // 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, - request_id, &path, msg.cluster, peer, &mut attached, - token_grant, + token_grant.clone(), ) - .await; + .await + }) + .await; - if attached { - self.stop_announce(path, token)?; + // 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; + } + 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?; + } } - - res + Ok(()) } /// Whether `type_id` retracts a PUBLISH_NAMESPACE rather than updating it. @@ -1419,29 +1460,27 @@ 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 /// closed stream when it cannot be applied, which withdraws the advertisement /// (moq-transport Section 9.5.1). - #[allow(clippy::too_many_arguments)] async fn run_publish_namespace_updates( &mut self, stream: &mut Stream, - request_id: RequestId, path: &PathOwned, mut held: Option, peer: cluster::Peer, attached: &mut bool, - mut token_grant: Option, - ) -> 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 and the old grant's deadline in - // the loop's single wait (never a bare await), so the old deadline still fires while a - // slow acceptor decides and a peer ending the announce ends it whatever the acceptor - // does. Messages behind a pending verdict wait in `renewal`, handled in order once it - // resolves. + // 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. @@ -1457,8 +1496,6 @@ where WriterClosed(Result<(), Error>), // The read failed. Failed(Error), - // The request grant lapsed or was revoked: the announce ends, never the session. - Ended(crate::auth::Refusal), // A pending renewal's verdict resolved, for the update with this request id. Renewal(request_update::Verdict), } @@ -1476,11 +1513,6 @@ where { return Poll::Ready(Turn::WriterClosed(result)); } - if let Some(rg) = token_grant.as_mut() - && let Poll::Ready(refusal) = rg.poll_ended(waiter) - { - return Poll::Ready(Turn::Ended(refusal)); - } if let Poll::Ready(renewed) = renewal.poll(waiter) { return Poll::Ready(Turn::Renewal(renewed)); } @@ -1516,13 +1548,7 @@ where } // 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) => { - self.release_announce(path, attached, token_grant.is_some())?; - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; - } - return Ok(()); - } + Err(request_update::Overflow::Request) => return Ok(Withdraw::Close), } } // A terminal read while a verdict is pending is handled now, ahead of any queued @@ -1530,37 +1556,30 @@ where 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. finished = true; continue; } // Already finished: the advertisement outlived the peer's send direction until now. - Turn::WriterClosed(result) if finished => return result, + 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) { - return Ok(()); + if !super::request_stream::fin_cancels(self.version) { + stream.writer.closed().await?; } - return stream.writer.closed().await; + return Ok(Withdraw::Done); } Turn::Failed(err) => return Err(err), - Turn::Ended(refusal) => { - // A lapsed grant serves nothing more, however slowly the peer reads. - self.release_announce(path, attached, true)?; - self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) - .await?; - return Err(refusal.error); - } Turn::Renewal(request_update::Verdict { verdict, grant: res, request_id: rid, }) => { // Only a token-authorized announce starts a renewal. - let rg = token_grant.as_mut().expect("a renewal without a request grant"); + 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?; @@ -1569,11 +1588,7 @@ where // 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. - self.release_announce(path, attached, true)?; - self.write_error(stream, rid, &refusal.error, &refusal.reason).await?; - self.cancel_announce(stream, request_id, path, &refusal.error, &refusal.reason) - .await?; - return Ok(()); + return Ok(Withdraw::Cancel(rid, refusal)); } }; @@ -1598,7 +1613,7 @@ 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. On draft-16 the @@ -1617,13 +1632,11 @@ where // 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.release_announce(path, attached, token_grant.is_some())?; - self.write_error(stream, update_id, &err, "repeated AUTHORIZATION TOKEN") - .await?; - self.cancel_announce(stream, request_id, path, &err, "repeated AUTHORIZATION TOKEN") - .await?; - return Ok(()); + let refusal = crate::auth::Refusal { + error: Error::Decode(DecodeError::MalformedAuthToken), + reason: "repeated AUTHORIZATION TOKEN".to_string(), + }; + return Ok(Withdraw::Cancel(update_id, refusal)); } msg => msg?, }; @@ -1684,14 +1697,10 @@ where }; // An unroutable apply withdraws the announce whether or not a token also rides it. - if let Err(err) = &applied { + // The close is the withdrawal. + if let Err(err) = applied { 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; - } - return Ok(()); + return Ok(Withdraw::Reject(msg.request_id, err.into())); } // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant @@ -1741,6 +1750,8 @@ where }) .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; } @@ -1790,11 +1801,11 @@ where 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 { @@ -1804,7 +1815,7 @@ where .await?; } _ => { - stream.writer.varint(ietf::RequestOk::ID).await?; + stream.writer.buffer_varint(ietf::RequestOk::ID)?; stream .writer .encode(&ietf::RequestOk { @@ -1829,7 +1840,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 { @@ -1840,7 +1851,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 { @@ -1852,7 +1863,7 @@ where .await?; } _ => { - stream.writer.varint(ietf::RequestError::ID).await?; + stream.writer.buffer_varint(ietf::RequestError::ID)?; stream .writer .encode(&ietf::RequestError { @@ -7812,7 +7823,6 @@ mod tests { let err = subscriber .run_publish_namespace_updates( &mut stream, - RequestId(1), &crate::Path::new("room/host"), Some(clean), peer, @@ -7844,7 +7854,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -7892,7 +7901,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -7951,7 +7959,7 @@ mod tests { 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 mut requests = auth.scoped().unwrap(); let token = crate::setup::Token { kind: 300, value: vec![0x00, 0xff], @@ -7962,12 +7970,7 @@ mod tests { crate::auth::RequestKind::PublishNamespace, Some(hold.clone()), ); - let _issued = requests - .next() - .await - .unwrap() - .request() - .accept(crate::auth::Grant::all()); + let _issued = requests.next().await.unwrap().accept(crate::auth::Grant::all()); let answered = verdict.grant().await; let grant = crate::auth::RequestGrant::new( &subscriber.runtime, @@ -7988,7 +7991,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -8058,7 +8060,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(held.clone()), peer, @@ -8108,7 +8109,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -8125,11 +8125,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); } @@ -8156,7 +8160,6 @@ mod tests { { let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -8217,7 +8220,6 @@ mod tests { let mut attached = true; let mut run = std::pin::pin!(subscriber.run_publish_namespace_updates( &mut stream, - RequestId(1), &path, Some(clean.clone()), peer, @@ -8684,7 +8686,7 @@ mod tests { first_script: Vec, ) -> ( Subscriber, - crate::auth::Requests, + crate::auth::Requests, crate::auth::Token, origin::Consumer, crate::lite::test_transport::ScriptedSession, @@ -8697,15 +8699,15 @@ mod tests { /// [`auth_announce_harness`] over a given session, such as one that finishes the stream /// after its script. - fn auth_announce_harness_on( + fn auth_announce_harness_on( version: Version, - session: crate::lite::test_transport::ScriptedSession, + session: S, ) -> ( - Subscriber, - crate::auth::Requests, + Subscriber, + crate::auth::Requests, crate::auth::Token, origin::Consumer, - crate::lite::test_transport::ScriptedSession, + S, ) { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); @@ -8715,7 +8717,7 @@ mod tests { peer_setup.set(peer::Peer::default()); let auth = crate::auth::Handle::new(true); - let requests = auth.requests().unwrap(); + let requests = auth.scoped().unwrap(); let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -8827,7 +8829,7 @@ mod tests { let acceptor = async { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); held.push(request.accept(crate::auth::Grant::all())); } }; @@ -8852,6 +8854,95 @@ mod tests { 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. @@ -8907,7 +8998,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -8951,7 +9042,7 @@ mod tests { let acceptor = { let consulted = consulted.clone(); async move { - let request = requests.next().await.expect("a request").request(); + 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 @@ -9025,7 +9116,7 @@ mod tests { 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").request(); + let request = requests.next().await.expect("a request"); let _held = request.accept(crate::auth::Grant::all()); std::future::pending::<()>().await }; @@ -9081,10 +9172,10 @@ mod tests { 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").request(); + 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").request(); + 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 @@ -9150,7 +9241,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); let _held = request.accept(publish_grant_expiring(&rt, Some(60))); std::future::pending::<()>().await }; @@ -9215,7 +9306,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let request = requests.next().await.expect("a request").request(); + let request = requests.next().await.expect("a request"); let _held = request.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9258,10 +9349,10 @@ mod tests { let answered = answered.clone(); async move { // The first grant lasts 60s; the renewal is refused. - let first = requests.next().await.expect("a request").request(); + 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").request(); + 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 @@ -9358,9 +9449,9 @@ mod tests { let rt = rt.clone(); async move { let mut held = Vec::new(); - let first = requests.next().await.expect("a request").request(); + 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").request(); + let renewal = requests.next().await.expect("a renewal"); held.push(renewal.accept(publish_grant_expiring(&rt, None))); std::future::pending::<()>().await } @@ -9443,15 +9534,15 @@ mod tests { let answered = answered.clone(); let release = release.clone(); async move { - let first = requests.next().await.expect("a request").request(); + 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").request(); + 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").request(); + 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); } @@ -9505,7 +9596,7 @@ mod tests { // 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").request(); + let first = requests.next().await.expect("a request"); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9562,7 +9653,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").request(); + let first = requests.next().await.expect("a request"); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9605,7 +9696,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").request(); + let first = requests.next().await.expect("a request"); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9647,7 +9738,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").request(); + let first = requests.next().await.expect("a request"); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9695,7 +9786,7 @@ mod tests { let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); let acceptor = async move { - let first = requests.next().await.expect("a request").request(); + let first = requests.next().await.expect("a request"); let _held = first.accept(publish_grant_expiring(&rt, None)); std::future::pending::<()>().await }; @@ -9733,7 +9824,7 @@ mod tests { limit: Option, ) -> ( Subscriber, - crate::auth::Requests, + crate::auth::Requests, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -9750,7 +9841,7 @@ mod tests { auth: crate::auth::Handle, ) -> ( Subscriber, - crate::auth::Requests, + crate::auth::Requests, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -9762,7 +9853,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let requests = auth.requests().unwrap(); + let requests = auth.scoped().unwrap(); let subscriber = Subscriber::new( crate::time::Clock::sim(), @@ -9801,7 +9892,7 @@ mod tests { async move { let mut held = Vec::new(); loop { - let request = requests.next().await.expect("a request reaches the acceptor").request(); + 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())); } @@ -9843,7 +9934,7 @@ mod tests { 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(); + let request = requests.next().await.expect("a request reaches the acceptor"); request.reject(crate::SessionError::Unauthorized, "no"); std::future::pending::<()>().await }; @@ -9886,10 +9977,10 @@ mod tests { let acceptor = { let popped = popped.clone(); async move { - let first = requests.next().await.expect("a request").request(); + 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").request(); + let _never_answered = requests.next().await.expect("a renewal"); popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await } @@ -9944,7 +10035,7 @@ mod tests { 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(); + let request = requests.next().await.expect("a request reaches the acceptor"); let _issued = request.accept(grant); std::future::pending::<()>().await }; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 15cdf4ee91..55fbe18a07 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -462,10 +462,7 @@ impl AuthServe { let issue = serving.issue.clone(); match self.shared.auth.acceptor() { Some(requests) => { - requests.push(crate::auth::Event::Session(crate::auth::Request::new( - msg.token, - issue.clone(), - ))); + requests.push(crate::auth::Request::new(msg.token, issue.clone())); } // Only the connection's own credential has a default answer; a token // needs someone to verify it. Tell the presenter explicitly. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 7742e6d259..1615d7419b 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1219,14 +1219,14 @@ mod tests { params } - /// An acceptor taken with `Request::auth().requests()` BEFORE `ok()` must govern the + /// A verifier taken with `Request::auth().scoped()` 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 acceptor. + /// 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_requests_consumer_governs_the_session_acceptor() { + async fn a_pre_ok_verifier_governs_the_session() { let cases = [ ( FakeSession::new(ALPN_14, []) @@ -1245,8 +1245,8 @@ mod tests { .await .unwrap_or_else(|e| panic!("accept at {version:?}: {e}")); - // Install the acceptor before ok(), as a QUIC server must. - let mut requests = request.auth().requests().expect("requests() pre-ok"); + // Install the verifier before ok(), as a QUIC server must. + let mut requests = request.auth().scoped().expect("scoped() 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 @@ -1261,11 +1261,7 @@ mod tests { crate::auth::RequestKind::PublishNamespace, None, ); - let received = requests - .next() - .await - .expect("the pre-ok consumer receives the request") - .request(); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); assert_eq!(received.path().as_str(), "room/alice", "{version:?}"); assert_eq!( received.kind(), @@ -1388,7 +1384,7 @@ mod tests { value: b"jwt".to_vec(), }; assert!(matches!( - request.auth().set_request_token(token), + request.auth().set_request_token(Some(token)), Err(Error::Unsupported) )); } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 5a07f53399..07d020aa57 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -233,7 +233,10 @@ struct Pair { server: Session, client_transport: MockSession, server_transport: MockSession, + /// The server's session tokens, when it took them. requests: Option, + /// The server's request tokens, when it took them. + scoped: Option>, /// Aborting it drops the server's driver without letting it finish. server_driver: moq_net_sim::JoinHandle, } @@ -278,7 +281,7 @@ async fn connect(opts: Options) -> Pair { if let Some(token) = client_token { session .auth() - .set_request_token(token) + .set_request_token(Some(token)) .expect("a moq-transport session carries a request token"); } spawn(driver); @@ -293,13 +296,15 @@ async fn connect(opts: Options) -> Pair { let requests = opts .server_requests .then(|| handshake.auth().requests().expect("requests available before ok()")); + // moq-lite carries no request token, so it has none to verify. + let scoped = opts.server_requests.then(|| handshake.auth().scoped().ok()).flatten(); let (session, mut driver) = handshake.ok().await.expect("server accept"); let driver = moq_net_sim::spawn(moq_net_sim::drive(move |now, waiter| { moq_net::time::Driver::poll(&mut driver, now, waiter) })); - (session, requests, driver) + (session, requests, scoped, driver) }; - let (client, (server, requests, server_driver)) = futures::join!(client_fut, server_fut); + let (client, (server, requests, scoped, server_driver)) = futures::join!(client_fut, server_fut); Pair { client, @@ -307,10 +312,20 @@ async fn connect(opts: Options) -> Pair { client_transport: observe, server_transport: observe_server, requests, + scoped, server_driver, } } +impl Pair { + /// The server's token queues, which it took before `ok()`: request tokens only where the + /// version carries them. + fn verifiers(&mut self) -> (auth::Requests, Option>) { + let requests = self.requests.take().expect("server took its requests pre-ok"); + (requests, self.scoped.take()) + } +} + /// A grant [`serve`] issued: to a session token, or to a request token. enum Answered { Session(auth::Issued), @@ -343,20 +358,42 @@ impl Answered { /// Answer the peer's tokens from a table, holding every grant (and any request the /// table leaves unanswered) for as long as the returned task lives. fn serve( - mut requests: auth::Requests, + (mut requests, mut scoped): (auth::Requests, Option>), answer: impl Fn(&[u8]) -> Option + 'static, ) -> futures::channel::mpsc::UnboundedReceiver<(Vec, Answered)> { + enum Next { + Session(auth::Request), + Request(auth::Scoped), + } let (tx, rx) = futures::channel::mpsc::unbounded(); moq_net_sim::spawn(async move { let mut unanswered = Vec::new(); - while let Some(event) = requests.next().await { - let (token, answered) = match event { - auth::Event::Session(request) => { + // A session without AUTH closes its session queue at once; request tokens still arrive. + let (mut sessions, mut requested) = (true, scoped.is_some()); + while sessions || requested { + let next = kio::wait(|waiter| { + if sessions && let std::task::Poll::Ready(next) = requests.poll_next(waiter) { + sessions = next.is_some(); + return std::task::Poll::Ready(next.map(Next::Session)); + } + if requested + && let Some(scoped) = scoped.as_mut() + && let std::task::Poll::Ready(next) = scoped.poll_next(waiter) + { + requested = next.is_some(); + return std::task::Poll::Ready(next.map(Next::Request)); + } + std::task::Poll::Pending + }) + .await; + let (token, answered) = match next { + None => continue, + Some(Next::Session(request)) => { let token = request.token().to_vec(); let answered = answer(&token).map(|grant| Answered::Session(request.accept(grant))); (token, answered) } - auth::Event::Request(request) => { + Some(Next::Request(request)) => { let token = request.token().value.clone(); let answered = match answer(&token) { Some(grant) => Some(Answered::Request(request.accept(grant))), @@ -367,7 +404,6 @@ fn serve( }; (token, answered) } - _ => unreachable!("an unknown token kind"), }; if let Some(answered) = answered { let _ = tx.unbounded_send((token, answered)); @@ -489,8 +525,7 @@ async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { // The server answers the request token from its acceptor with a grant covering the // announced path. Held for the test by the returned receiver. - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); // The client announces under the token. On the legacy path there is no session grant, // so the token is the only authorization for the announce. @@ -527,8 +562,7 @@ async fn a_request_token_renews_a_legacy_announce_through_the_driver() { ..Default::default() }) .await; - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &[]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &[]))); let bc = publisher.create_broadcast("room/alice").unwrap(); bc.announce(Default::default()).unwrap(); @@ -536,7 +570,10 @@ async fn a_request_token_renews_a_legacy_announce_through_the_driver() { assert_eq!(token, b"aa"); wait_announced(&relay.consume(), "room/alice", true).await; - pair.client.auth().set_request_token(request_token(b"bb")).unwrap(); + pair.client + .auth() + .set_request_token(Some(request_token(b"bb"))) + .unwrap(); let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); assert_eq!(token, b"bb", "the replaced token rides the REQUEST_UPDATE"); @@ -596,8 +633,7 @@ async fn a_client_may_decline_the_auth_extension() { // The client's token-bearing SUBSCRIBE reaches the acceptor: on the `None`-union // session the covers-gate does not short-circuit, so the token is verified rather // than admitted by a covering connection grant. - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); @@ -633,7 +669,7 @@ async fn a_client_may_decline_the_auth_extension() { async fn a_moq_lite_session_refuses_a_request_token() { within(async { let lite = connect(Options::default()).await; - let refused = lite.client.auth().set_request_token(request_token(b"ok")); + let refused = lite.client.auth().set_request_token(Some(request_token(b"ok"))); assert!(matches!(refused, Err(Error::Unsupported)), "{refused:?}"); let ietf = connect(Options { @@ -643,7 +679,7 @@ async fn a_moq_lite_session_refuses_a_request_token() { .await; ietf.client .auth() - .set_request_token(request_token(b"ok")) + .set_request_token(Some(request_token(b"ok"))) .expect("a moq-transport session takes a request token"); }) .await @@ -675,8 +711,7 @@ async fn a_legacy_renewal_answer_reaches_its_subscription() { ..Default::default() }) .await; - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&[], &["room/alice"]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&[], &["room/alice"]))); let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); @@ -687,13 +722,19 @@ async fn a_legacy_renewal_answer_reaches_its_subscription() { assert_eq!(token, b"aa", "{version}"); sub.recv_group().await.unwrap().unwrap(); - pair.client.auth().set_request_token(request_token(b"bb")).unwrap(); + pair.client + .auth() + .set_request_token(Some(request_token(b"bb"))) + .unwrap(); let (token, _second) = answered.next().await.expect("the first renewal reached the acceptor"); assert_eq!(token, b"bb", "{version}"); // Sent only once the first renewal's answer came back to the subscription. moq_net_sim::sleep(Duration::from_millis(100)).await; - pair.client.auth().set_request_token(request_token(b"cc")).unwrap(); + pair.client + .auth() + .set_request_token(Some(request_token(b"cc"))) + .unwrap(); let (token, _third) = answered.next().await.expect("the second renewal reached the acceptor"); assert_eq!(token, b"cc", "{version}"); }) @@ -735,8 +776,7 @@ async fn a_request_token_renews_a_subscription_through_the_driver() { let expires = Some(now() + Duration::from_secs(1)); // The acceptor sees the token's value. let renewal = second.value.clone(); - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, move |token| { + let mut answered = serve(pair.verifiers(), move |token| { let mut granted = grant(&[], &["room/alice"]); if token != renewal.as_slice() { granted.expires = expires; @@ -753,7 +793,7 @@ async fn a_request_token_renews_a_subscription_through_the_driver() { assert_eq!(token, first.value); sub.recv_group().await.unwrap().unwrap(); - pair.client.auth().set_request_token(second.clone()).unwrap(); + pair.client.auth().set_request_token(Some(second.clone())).unwrap(); let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); assert_eq!(token, second.value, "the replaced token rides the REQUEST_UPDATE"); @@ -826,14 +866,14 @@ async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { // SUBSCRIBE, so nothing reaches it until we subscribe). It reports every token it // verifies and holds the first renewal's verifier until released, the way a relay with // a slow authorizer would. - let mut requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut requests = pair.scoped.take().expect("server took its request tokens pre-ok"); let (seen_tx, mut seen) = futures::channel::mpsc::unbounded::>(); let (release, release_waiter) = futures::channel::oneshot::channel::<()>(); let acceptor = moq_net_sim::spawn(async move { let mut issued = Vec::new(); let mut count = 0u64; let mut release_waiter = Some(release_waiter); - while let Some(auth::Event::Request(request)) = requests.next().await { + while let Some(request) = requests.next().await { let token = request.token().value.clone(); count += 1; seen_tx.unbounded_send(token).ok(); @@ -864,7 +904,7 @@ async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { // Replace the token once and let that one renewal reach the held acceptor, so the held // renewal is deterministic regardless of scheduling. - pair.client.auth().set_request_token(token(1)).unwrap(); + pair.client.auth().set_request_token(Some(token(1))).unwrap(); assert_eq!( seen.next().await.expect("first renewal"), token(1).value, @@ -878,7 +918,7 @@ async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { // would close the session with TOO_MANY_REQUEST_UPDATES, losing every request on it; // the one-in-flight rule coalesces them behind the held one instead. for n in 2..=64u8 { - pair.client.auth().set_request_token(token(n)).unwrap(); + pair.client.auth().set_request_token(Some(token(n))).unwrap(); moq_net_sim::sleep(Duration::from_millis(5)).await; } @@ -957,8 +997,7 @@ async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announc let bc = publisher.create_broadcast("room/alice").unwrap(); bc.announce(Default::default()).unwrap(); - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); // The announce is admitted once the token is granted. wait_announced(&relay.consume(), "room/alice", true).await; let (tok, _issued) = answered @@ -986,8 +1025,7 @@ async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announc let bc = publisher.create_broadcast("room/carol").unwrap(); bc.announce(Default::default()).unwrap(); - let requests = pair.requests.take().expect("server took its requests pre-ok"); - let mut answered = serve(requests, |_token| Some(grant(&["room/carol"], &[]))); + let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/carol"], &[]))); wait_announced(&relay.consume(), "room/carol", true).await; assert!( answered.try_recv().is_err(), @@ -1039,9 +1077,7 @@ async fn an_unanswered_token_does_not_suspend_the_check(version: &'static str) { ..Default::default() }) .await; - let _issued = serve(pair.requests.take().unwrap(), |token| { - token.is_empty().then(|| grant(&["baz"], &[])) - }); + let _issued = serve(pair.verifiers(), |token| token.is_empty().then(|| grant(&["baz"], &[]))); let auth = pair.client.auth(); let pending = moq_net_sim::spawn(async move { auth.add("never answered").await.map(|_| ()) }); @@ -1074,7 +1110,7 @@ async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + let mut issued = serve(pair.verifiers(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1117,7 +1153,7 @@ async fn an_update_replaces_one_tokens_grant(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + let mut issued = serve(pair.verifiers(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1148,7 +1184,7 @@ async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + let mut issued = serve(pair.verifiers(), |token| match token { b"" | b"again" => Some(grant(&["a"], &[])), _ => None, }); @@ -1185,7 +1221,7 @@ async fn a_refused_token_reports_the_code(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); moq_net_sim::spawn(async move { - while let Some(auth::Event::Session(request)) = requests.next().await { + while let Some(request) = requests.next().await { match request.token().is_empty() { true => { std::mem::forget(request.accept(Grant::default())); @@ -1220,7 +1256,7 @@ async fn a_refused_setup_token_grants_nothing(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); moq_net_sim::spawn(async move { - while let Some(auth::Event::Session(request)) = requests.next().await { + while let Some(request) = requests.next().await { request.reject(SessionError::Unauthorized, "bad credential"); } }); @@ -1243,7 +1279,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); // Wait for the setup token to be queued, then hand it back to the queue's owner. - let Some(auth::Event::Session(first)) = requests.next().await else { + let Some(first) = requests.next().await else { panic!("setup token") }; drop(first.accept(Grant::default())); @@ -1292,7 +1328,7 @@ async fn an_issued_grant_ends_with_its_session(version: &'static str) { }) .await; let mut requests = pair.requests.take().unwrap(); - let Some(auth::Event::Session(setup)) = requests.next().await else { + let Some(setup) = requests.next().await else { panic!("setup token") }; let issued = setup.accept(Grant::default()); @@ -1398,7 +1434,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| { + let mut issued = serve(pair.verifiers(), |token| { token.is_empty().then(|| grant(&["up"], &["room"])) }); let (_, setup) = issued.next().await.unwrap(); @@ -1465,7 +1501,7 @@ async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + let mut issued = serve(pair.verifiers(), |token| match token { b"" => Some(grant(&["a"], &[])), b"exact" => Some(Grant { publish: Patterns::from(Pattern::try_from("room/alice").unwrap()), @@ -1514,7 +1550,7 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + let mut issued = serve(pair.verifiers(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1557,7 +1593,7 @@ async fn a_grant_too_large_for_one_message_is_unsupported(version: &'static str) let huge: Patterns = (0..count) .map(|i| Pattern::subtree(&format!("{i}{}", "x".repeat(len))).unwrap()) .collect(); - let mut issued = serve(pair.requests.take().unwrap(), move |token| match token { + let mut issued = serve(pair.verifiers(), move |token| match token { b"" => Some(grant(&["a"], &[])), b"huge" => Some(Grant { publish: huge.clone(), @@ -1589,7 +1625,7 @@ async fn a_grant_update_too_large_for_one_message_is_unsupported(version: &'stat ..Default::default() }) .await; - let mut issued = serve(pair.requests.take().unwrap(), |token| { + let mut issued = serve(pair.verifiers(), |token| { Some(grant(if token.is_empty() { &["a"] } else { &["b"] }, &[])) }); let (_, _setup) = issued.next().await.unwrap(); @@ -1645,7 +1681,7 @@ async fn pattern_grants_arrive_exactly(version: &'static str) { expires: None, }) }; - let mut issued = serve(pair.requests.take().unwrap(), table); + let mut issued = serve(pair.verifiers(), table); let (_, _setup) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, table(b"").unwrap()); @@ -1723,7 +1759,7 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { // Hold the answer, so the peer's discovery request is in long before the grant. let mut requests = pair.requests.take().unwrap(); - let Some(auth::Event::Session(setup)) = requests.next().await else { + let Some(setup) = requests.next().await else { panic!("setup token") }; let leaked = moq_net_sim::timeout( diff --git a/rs/moq-relay/src/auth/refresh.rs b/rs/moq-relay/src/auth/refresh.rs index 165859e080..0c50dde4a9 100644 --- a/rs/moq-relay/src/auth/refresh.rs +++ b/rs/moq-relay/src/auth/refresh.rs @@ -230,7 +230,7 @@ impl Refresh { pub(super) async fn ended(&mut self) -> lease::Reason { loop { enum Event { - Request(Option), + Request(Option), Verified(Option, Option), Settled, Changed(u64, Change), @@ -250,14 +250,7 @@ impl Refresh { } }; match event { - Event::Request(Some(auth::Event::Session(request))) => self.request(request), - // A request token would authorize one request beyond every credential, which - // the relay's grants do not model. - Event::Request(Some(auth::Event::Request(scoped))) => { - scoped.reject(SessionError::Unsupported, "request tokens are not verified") - } - // Dropping a token of a kind added later refuses it. - Event::Request(Some(_)) => {} + Event::Request(Some(request)) => self.request(request), Event::Request(None) => self.requests_open = false, Event::Settled => {} Event::Verified(verdict, settling) => { diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index e9d7cb7df9..59cf1b5947 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2048,7 +2048,7 @@ mod tests { "seeded by the config" ); - conn.auth().set_request_token(tok(b"token-three")).unwrap(); + conn.auth().set_request_token(Some(tok(b"token-three"))).unwrap(); assert_eq!( format!("{:?}", conn.request_token), "kind=0 <11 bytes>", @@ -2127,22 +2127,15 @@ mod tests { let _ = release.await; let request = request.with_subscriber(crate::origin::spawn()); - let mut requests = request.auth().requests().expect("requests are taken before ok"); + let mut requests = request.auth().scoped().expect("request tokens are taken before ok"); let _session = request.ok().await.expect("accept"); let mut first_tx = Some(first_tx); - let mut sessions = Vec::new(); let mut scoped = Vec::new(); - while let Some(event) = requests.next().await { - match event { - moq_net::auth::Event::Session(req) => sessions.push(req.accept(moq_net::auth::Grant::all())), - moq_net::auth::Event::Request(req) => { - if let Some(tx) = first_tx.take() { - let _ = tx.send(req.token().value.clone()); - } - scoped.push(req.accept(moq_net::auth::Grant::all())); - } - _ => {} + while let Some(req) = requests.next().await { + if let Some(tx) = first_tx.take() { + let _ = tx.send(req.token().value.clone()); } + scoped.push(req.accept(moq_net::auth::Grant::all())); } }); (url, first_rx) @@ -2203,7 +2196,7 @@ mod tests { let (conn, _broadcast) = announcing_client(tok(b"a"), url); paused.await.expect("the client's SETUP reached the server"); - conn.auth().set_request_token(tok(b"b")).unwrap(); + conn.auth().set_request_token(Some(tok(b"b"))).unwrap(); release.send(()).unwrap(); let first = tokio::time::timeout(Duration::from_secs(10), first) diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index adb470661f..0ad3148e72 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -19,14 +19,18 @@ pub struct Auth { impl Auth { /// Present `token` as the `AUTHORIZATION TOKEN` on this connection's SUBSCRIBE and - /// PUBLISH_NAMESPACE requests, now and after reconnects. Experimental: the peer's - /// application must verify it, and success means only that the token was set. + /// PUBLISH_NAMESPACE requests, now and after reconnects, or stop presenting one with + /// `None`. Experimental: the peer's application must verify it, and success means only + /// that the token was set. See [`moq_net::auth::Handle::set_request_token`]. /// /// Replacing it renews the live session's token-authorized requests in band, except a - /// draft 14 or 15 advertisement, which has no update. Errors - /// with [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose - /// requests carry no token; later sessions are still seeded with it. - pub fn set_request_token(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + /// draft 14 or 15 advertisement, which has no update. + /// + /// # Errors + /// + /// [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose requests + /// carry no token. The token is then not kept, so later sessions keep the one set before. + pub fn set_request_token(&self, token: Option) -> Result<(), moq_net::Error> { self.request.set(token) } @@ -82,13 +86,13 @@ struct Presenting { /// Where a request token is presented: a session's auth handle. pub(crate) trait Present: Send { - /// Present `token` on this session's requests from now on, or refuse it when the session - /// cannot carry one (moq-lite). - fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error>; + /// Present `token` on this session's requests from now on (or none), or refuse it when + /// the session cannot carry one (moq-lite). + fn present(&self, token: Option) -> Result<(), moq_net::Error>; } impl Present for auth::Handle { - fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + fn present(&self, token: Option) -> Result<(), moq_net::Error> { self.set_request_token(token) } } @@ -103,14 +107,15 @@ impl RequestToken { /// Replace the token, on the newest session too, and seed every later session with it. /// - /// Errors when the newest session cannot carry it; later sessions are still seeded. - fn set(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + /// Errors when the newest session cannot carry it, keeping the token set before: one the + /// live session refused would only fail the next reconnect for good. + fn set(&self, token: Option) -> Result<(), moq_net::Error> { let mut presenting = self.0.lock().unwrap(); - presenting.token = Some(token.clone()); - match &presenting.session { - Some(session) => session.present(token), - None => Ok(()), + if let Some(session) = &presenting.session { + session.present(token.clone())?; } + presenting.token = token; + Ok(()) } /// Seed `session` with the current token and make it the session later renewals reach. @@ -128,8 +133,8 @@ impl RequestToken { )] pub(crate) fn attach(&self, session: impl Present + 'static) -> Result<(), moq_net::Error> { let mut presenting = self.0.lock().unwrap(); - if let Some(token) = &presenting.token { - session.present(token.clone())?; + if presenting.token.is_some() { + session.present(presenting.token.clone())?; } presenting.session = Some(Box::new(session)); Ok(()) @@ -425,7 +430,8 @@ mod request_token_tests { } impl Present for Parked { - fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + fn present(&self, token: Option) -> Result<(), moq_net::Error> { + let token = token.expect("the tests present a token"); let park = self.park.lock().unwrap().take(); if let Some((parked, release)) = park { parked.send(()).unwrap(); @@ -464,7 +470,7 @@ mod request_token_tests { let renewer = { let token = token.clone(); - std::thread::spawn(move || token.set(renew).unwrap()) + std::thread::spawn(move || token.set(Some(renew)).unwrap()) }; release.wait(); writer.join().expect("writer panicked"); @@ -476,13 +482,17 @@ mod request_token_tests { struct Refuses; impl Present for Refuses { - fn present(&self, _token: moq_net::setup::Token) -> Result<(), moq_net::Error> { - Err(moq_net::Error::Unsupported) + fn present(&self, token: Option) -> Result<(), moq_net::Error> { + match token { + Some(_) => Err(moq_net::Error::Unsupported), + None => Ok(()), + } } } /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial - /// does too, and a renewal reaching it fails while still seeding later sessions. + /// does too, and a renewal reaching it fails without replacing the token later sessions + /// are seeded with, so the next reconnect does not fail for good on it. #[test] fn a_session_that_cannot_carry_the_token_refuses_it() { let token = RequestToken::new(Some(tok(b"token-a"))); @@ -490,8 +500,30 @@ mod request_token_tests { let token = RequestToken::new(None); token.attach(Refuses).expect("nothing to present"); - assert!(matches!(token.set(tok(b"token-b")), Err(moq_net::Error::Unsupported))); - assert_eq!(token.get(), Some(tok(b"token-b")), "later sessions are still seeded"); + assert!(matches!( + token.set(Some(tok(b"token-b"))), + Err(moq_net::Error::Unsupported) + )); + assert_eq!(token.get(), None, "a refused token is not kept"); + token.set(None).expect("clearing is always carried"); + } + + /// Clearing the token stops presenting it on the live session and seeds later ones with none. + #[test] + fn clearing_the_token_reaches_the_session() { + let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + struct Records(std::sync::Arc>>>); + impl Present for Records { + fn present(&self, token: Option) -> Result<(), moq_net::Error> { + self.0.lock().unwrap().push(token); + Ok(()) + } + } + let token = RequestToken::new(Some(tok(b"token-a"))); + token.attach(Records(presented.clone())).unwrap(); + token.set(None).unwrap(); + assert_eq!(*presented.lock().unwrap(), [Some(tok(b"token-a")), None]); + assert_eq!(token.get(), None, "later sessions carry none"); } /// A reconnect seeding its new session cannot replay the token it read over a renewal that @@ -523,7 +555,7 @@ mod request_token_tests { move |token, session| { // Nothing to seed, so the attach presents nothing and the renewal parks. token.attach(session).unwrap(); - token.set(a).unwrap(); + token.set(Some(a)).unwrap(); } }, b.clone(), @@ -542,7 +574,7 @@ mod request_token_tests { token.attach(session()).unwrap(); token.detach(); - token.set(tok(b"token-b")).unwrap(); + token.set(Some(tok(b"token-b"))).unwrap(); assert_eq!( presented.lock().unwrap().len(), 1, @@ -600,7 +632,7 @@ mod tests { let acceptor = tokio::spawn(async move { let _session = session; let mut initial = Vec::new(); - while let Some(auth::Event::Session(request)) = requests.next().await { + while let Some(request) = requests.next().await { if request.token().is_empty() { initial.push(request.accept(auth::Grant::default())); } else if sender.send(request).is_err() { From 5a4728e97da96de69559387dcd888821940292e6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 11:01:28 -0700 Subject: [PATCH 22/53] fix(net): a cleared request token keeps the live announces it backed Clearing the token made the next reprice or regrant judge a live token-backed advertisement by the connection grant alone and withdraw it, although the peer still held it on the token. Each live advertisement now records whether it presented a token, from the PUBLISH_NAMESPACE or an accepted renewal, and keeps standing on it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 102 ++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 9f1537eb89..da3fce0292 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2463,8 +2463,10 @@ where // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE // entry has no slot for one. So the grant still filters inline entries, and stands down // only for requests, where the server's covers-gate refuses a bad token per request. - // The token never stands in for the limit this side set on the peer. - let carries_token = matches!(ns.target, Target::Requests(_)) && self.request_token.peek().is_some(); + // The token never stands in for the limit this side set on the peer. A live request that + // presented one keeps standing on it after the token is cleared, as the peer still does. + let carries_token = matches!(ns.target, Target::Requests(_)) + && (self.request_token.peek().is_some() || ns.requests.get(suffix).is_some_and(|request| request.token)); let permitted = match carries_token { true => ns.permit.within_limit(path.as_str()), false => ns.permitted(path), @@ -2603,6 +2605,8 @@ where return Ok(Refused::No); }; + let token = self.request_token.peek(); + let presented = token.is_some(); request.writer.varint(ietf::PublishNamespace::ID).await?; request .writer @@ -2610,7 +2614,7 @@ where request_id, track_namespace: path.as_path(), cluster, - authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), + authorization_token: token.map(crate::ietf::token::RequestToken), }) .await?; @@ -2661,6 +2665,7 @@ where path: path.clone(), request_id, stream: request, + token: presented, }, ); Ok(Refused::No) @@ -2933,6 +2938,9 @@ where ietf::RequestOk::ID => { let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; tracing::debug!(broadcast = %absolute, "request token refreshed"); + if let Some(request) = ns.requests.get_mut(&suffix) { + request.token = true; + } } ietf::RequestError::ID => { let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; @@ -4193,6 +4201,9 @@ struct NamespaceRequest { path: crate::PathOwned, request_id: RequestId, stream: Stream, + /// The advertisement presented a request token, so the peer holds it on that token's + /// grant rather than the connection grant. + token: bool, } #[cfg(test)] @@ -13036,6 +13047,91 @@ mod tests { ); } + /// Clearing the request token leaves a live advertisement on the token it presented: a + /// later reprice of a namespace outside the connection grant updates it in place rather + /// than withdrawing what the peer still holds on that token. + #[moq_net_sim::test] + async fn a_cleared_token_keeps_its_live_announce() { + const VERSION: Version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // Forward the reprice at once: the update hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + // One epoch, so the warm route re-prices the instance rather than restart it. + let epoch = crate::Epoch::mint(); + let _cold = origin + .announce( + "cam", + crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + ) + .unwrap(); + settle().await; + + // A connection grant of "other", which does not cover "cam". + 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 { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let log = session.log.clone(); + let request_token = crate::RequestToken::new(Some(typed(&token))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ) + .with_auth(auth) + .with_request_token(request_token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + request_token.set(None); + let after_announce = log.writes.lock().unwrap().len(); + let _warm = origin + .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .unwrap(); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if log.writes.lock().unwrap().len() > after_announce { + break; + } + settle().await; + } + assert!(log.resets().is_empty(), "the live announce was withdrawn"); + assert!( + log.writes.lock().unwrap().len() > after_announce, + "the reprice update was never sent" + ); + } + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. fn typed(raw: &[u8]) -> crate::setup::Token { assert_eq!(raw[0], 0x03, "USE_VALUE"); From 81ec03fc1a676bbdc1ac8c67afdb688db7f92edb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 11:42:15 -0700 Subject: [PATCH 23/53] fix(net): clearing the request token resumes grant enforcement Setting a token ended the dialing side's grant enforcement for good, so after `set_request_token(None)` a broadcast outside the connection grant no longer closed the session. Enforcement now follows every token change. Broadcasts announced while the token rode the announces stay exempt, since their advertisements still stand on it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 27 +++++++++++++- rs/moq-net/src/ietf/session.rs | 65 ++++++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e962fdf3b3..b5b3e133e4 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -342,8 +342,9 @@ impl Handle { /// either: live requests keep the token they last presented, and later ones carry none. /// Independent of the MoQ Auth extension. /// - /// Once a token is set, this side stops closing the session over a broadcast its own + /// While a token is set, this side stops closing the session over a broadcast its own /// grant does not cover, since each announce carries the token for the peer to verify. + /// Clearing it resumes that check for broadcasts announced afterwards. /// /// # Errors /// @@ -1810,6 +1811,30 @@ impl Enforce { } } } + + /// Admit every broadcast announced while our announces carry a request token, unchecked, + /// so clearing the token checks only what is announced after it. Ready once the origin ends. + pub(crate) fn poll_exempt(&mut self, announced: &mut crate::announce::Consumer, waiter: &kio::Waiter) -> Poll<()> { + loop { + let Some(event) = ready_or!(announced.poll_next(waiter)) else { + return Poll::Ready(()); + }; + match event { + crate::announce::Event::Start(update) | crate::announce::Event::Update(update) => { + self.live.insert(update.prefix); + } + crate::announce::Event::End(update) => { + self.live.remove(&update.prefix); + } + _ => {} + } + } + } + + /// Admit one broadcast unchecked, such as one a token set mid-check now carries. + pub(crate) fn admit(&mut self, path: crate::PathOwned) { + self.live.insert(path); + } } #[cfg(test)] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 602913ae98..d853c9ef2f 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1429,19 +1429,22 @@ async fn enforce_grant( // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still - // bounds what we publish. The token can be set at any time, so each change is rechecked. + // bounds what we publish. The token can be set or cleared at any time, so each change is + // rechecked. A broadcast announced while the token rode the announces stays exempt after it + // is cleared, since its advertisement still stands on that token. let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); let path = loop { let token = request_token.peek(); - if token.is_some() && announces_carry_token(&peer_setup, version).await { - return Ok(()); - } + let exempt = token.is_some() && announces_carry_token(&peer_setup, version).await; let next = kio::wait(|waiter| { if request_token.poll_changed(&token, waiter).is_ready() { return std::task::Poll::Ready(None); } - check.poll(&auth, &mut announced, waiter).map(Some) + match exempt { + true => check.poll_exempt(&mut announced, waiter).map(|()| Some(None)), + false => check.poll(&auth, &mut announced, waiter).map(Some), + } }) .await; match next { @@ -1451,7 +1454,8 @@ async fn enforce_grant( Some(Some(path)) => { // Another thread may have set a token after the change check in that same poll. if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { - return Ok(()); + check.admit(path); + continue; } break path; } @@ -1956,7 +1960,7 @@ mod tests { ..Default::default() }); - let result = enforce_grant( + let mut enforce = std::pin::pin!(enforce_grant( auth, origin.consume(), session, @@ -1966,13 +1970,14 @@ mod tests { })), peer_setup, Version::Draft18, - ) - .await; - - assert!( - result.is_ok(), - "a token-bearing client must not be closed by grant enforcement" - ); + )); + for _ in 0..ANNOUNCE_TURNS { + assert!( + futures::poll!(enforce.as_mut()).is_pending(), + "a token-bearing client must not be closed by grant enforcement" + ); + moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; + } assert!( log.closes().is_empty(), "the session must stay open for a token-bearing client" @@ -1982,7 +1987,7 @@ mod tests { /// A request token set after the session started stands enforcement down just the same: /// the announce that follows carries it, so the peer decides, not the connection grant. #[moq_net_sim::test] - async fn a_request_token_set_later_stands_enforcement_down() { + async fn a_request_token_stands_enforcement_down_until_cleared() { let auth = crate::auth::Handle::new(true); let setup = auth.present(bytes::Bytes::new(), true).unwrap(); auth.granted( @@ -2020,14 +2025,36 @@ mod tests { let _cam = origin .announce("room/bob/cam", crate::origin::Route::default()) .unwrap(); - let result = moq_net_sim::timeout(std::time::Duration::from_secs(1), enforce) - .await - .expect("enforcement never noticed the token"); - assert!(result.is_ok(), "{result:?}"); + for _ in 0..ANNOUNCE_TURNS { + assert!( + futures::poll!(enforce.as_mut()).is_pending(), + "enforcement closed the session" + ); + moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; + } assert!( log.closes().is_empty(), "the session stays open for the token-bearing announce" ); + + // Clearing the token leaves that announce standing on it, but the next one outside the + // grant carries no token, so the grant decides again. + request_token.set(None); + for _ in 0..ANNOUNCE_TURNS { + assert!( + futures::poll!(enforce.as_mut()).is_pending(), + "an announce made under the token was rechecked" + ); + moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; + } + let _carol = origin + .announce("room/carol/cam", crate::origin::Route::default()) + .unwrap(); + let result = moq_net_sim::timeout(std::time::Duration::from_secs(1), enforce) + .await + .expect("clearing the token never resumed enforcement"); + assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); + assert_eq!(log.closes().len(), 1, "the session closes for the token-less announce"); drop(setup); } From b01fb5c913c6ede27b675dd89744f10f34de2d48 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 12:14:14 -0700 Subject: [PATCH 24/53] fix(net): enforcement admits an advertisement by the token it carried Grant enforcement and the publisher read the origin through separate cursors. The publisher could advertise a broadcast carrying the token, and the token could be cleared, before enforcement reached it. Enforcement then closed the session over an advertisement the peer held on that token. Each advertisement now registers on the session's request token, under the token's lock, before its first byte goes out. Enforcement admits a broadcast while a token is set, or while a live advertisement of it carries one. This replaces the token-change tracking in enforcement. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 74 +++++++++++++++------- rs/moq-net/src/ietf/publisher.rs | 20 +++--- rs/moq-net/src/ietf/session.rs | 102 +++++++++++++++++++++++-------- 3 files changed, 140 insertions(+), 56 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index b5b3e133e4..5c1a9a5130 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -22,7 +22,8 @@ //! union of subtrees rather than widen it. use std::{ - collections::{BTreeMap, VecDeque}, + collections::{BTreeMap, HashMap, VecDeque}, + sync::{Arc, Mutex}, task::Poll, }; @@ -269,6 +270,9 @@ impl Permit { #[derive(Clone, Default)] pub(crate) struct RequestToken { token: kio::Shared>, + /// Broadcasts whose live advertisement presented the token, counted per advertisement. + /// They keep standing on it after it is cleared, so grant enforcement leaves them be. + backed: Arc>>, } impl RequestToken { @@ -277,9 +281,38 @@ impl RequestToken { pub(crate) fn new(token: Option) -> Self { Self { token: kio::Shared::new(token), + backed: Default::default(), } } + /// The token an advertisement of `path` presents now, with a guard marking `path` as backed + /// by it until dropped. Read under the token's lock, so a concurrent clear sees either no + /// token sent or the path already backed. + pub(crate) fn present(&self, path: &crate::PathOwned) -> (Option, Option) { + let token = self.token.read(); + let backing = token.is_some().then(|| self.back(path.clone())); + ((*token).clone(), backing) + } + + /// Mark `path` as backed by the token, such as after an accepted renewal. + pub(crate) fn back(&self, path: crate::PathOwned) -> Backing { + *self + .backed + .lock() + .unwrap_or_else(|e| e.into_inner()) + .entry(path.clone()) + .or_default() += 1; + Backing { + backed: self.backed.clone(), + path, + } + } + + /// Whether a live advertisement of `path` presented the token. + pub(crate) fn backs(&self, path: &crate::PathOwned) -> bool { + self.backed.lock().unwrap_or_else(|e| e.into_inner()).contains_key(path) + } + /// Replace the token presented on this session's requests. A live request re-presents /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. pub(crate) fn set(&self, token: Option) { @@ -308,6 +341,24 @@ impl RequestToken { } } +/// One advertisement standing on the request token it presented; dropping it ends that. +pub(crate) struct Backing { + backed: Arc>>, + path: crate::PathOwned, +} + +impl Drop for Backing { + fn drop(&mut self) { + let mut backed = self.backed.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(count) = backed.get_mut(&self.path) { + *count -= 1; + if *count == 0 { + backed.remove(&self.path); + } + } + } +} + /// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). /// /// Cheap to clone; every clone shares the session's tokens. @@ -1812,26 +1863,7 @@ impl Enforce { } } - /// Admit every broadcast announced while our announces carry a request token, unchecked, - /// so clearing the token checks only what is announced after it. Ready once the origin ends. - pub(crate) fn poll_exempt(&mut self, announced: &mut crate::announce::Consumer, waiter: &kio::Waiter) -> Poll<()> { - loop { - let Some(event) = ready_or!(announced.poll_next(waiter)) else { - return Poll::Ready(()); - }; - match event { - crate::announce::Event::Start(update) | crate::announce::Event::Update(update) => { - self.live.insert(update.prefix); - } - crate::announce::Event::End(update) => { - self.live.remove(&update.prefix); - } - _ => {} - } - } - } - - /// Admit one broadcast unchecked, such as one a token set mid-check now carries. + /// Admit one broadcast [`Self::poll`] found uncovered, such as one a request token carries. pub(crate) fn admit(&mut self, path: crate::PathOwned) { self.live.insert(path); } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index da3fce0292..160e333c6e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2466,7 +2466,8 @@ where // The token never stands in for the limit this side set on the peer. A live request that // presented one keeps standing on it after the token is cleared, as the peer still does. let carries_token = matches!(ns.target, Target::Requests(_)) - && (self.request_token.peek().is_some() || ns.requests.get(suffix).is_some_and(|request| request.token)); + && (self.request_token.peek().is_some() + || ns.requests.get(suffix).is_some_and(|request| request.backing.is_some())); let permitted = match carries_token { true => ns.permit.within_limit(path.as_str()), false => ns.permitted(path), @@ -2605,8 +2606,9 @@ where return Ok(Refused::No); }; - let token = self.request_token.peek(); - let presented = token.is_some(); + // Backed from before the first byte, so grant enforcement never sees this advertisement + // carry a token it does not know about. + let (token, backing) = self.request_token.present(path); request.writer.varint(ietf::PublishNamespace::ID).await?; request .writer @@ -2665,7 +2667,7 @@ where path: path.clone(), request_id, stream: request, - token: presented, + backing, }, ); Ok(Refused::No) @@ -2939,7 +2941,9 @@ where let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; tracing::debug!(broadcast = %absolute, "request token refreshed"); if let Some(request) = ns.requests.get_mut(&suffix) { - request.token = true; + request + .backing + .get_or_insert_with(|| self.request_token.back(request.path.clone())); } } ietf::RequestError::ID => { @@ -4201,9 +4205,9 @@ struct NamespaceRequest { path: crate::PathOwned, request_id: RequestId, stream: Stream, - /// The advertisement presented a request token, so the peer holds it on that token's - /// grant rather than the connection grant. - token: bool, + /// Set once the advertisement presented a request token, so the peer holds it on that + /// token's grant rather than the connection grant. + backing: Option, } #[cfg(test)] diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index d853c9ef2f..4530098e1a 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1429,37 +1429,23 @@ async fn enforce_grant( // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still - // bounds what we publish. The token can be set or cleared at any time, so each change is - // rechecked. A broadcast announced while the token rode the announces stays exempt after it - // is cleared, since its advertisement still stands on that token. + // bounds what we publish. The token can be set or cleared at any time, so it is read when + // an uncovered broadcast turns up: one is admitted while a token is set, or once cleared, if + // its live advertisement already presented one, since that still stands on it. The + // publisher marks that before the first byte, so this cursor lagging behind its own never + // mistakes a token-bearing advertisement for an unauthorized one. let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); let path = loop { - let token = request_token.peek(); - let exempt = token.is_some() && announces_carry_token(&peer_setup, version).await; - let next = kio::wait(|waiter| { - if request_token.poll_changed(&token, waiter).is_ready() { - return std::task::Poll::Ready(None); - } - match exempt { - true => check.poll_exempt(&mut announced, waiter).map(|()| Some(None)), - false => check.poll(&auth, &mut announced, waiter).map(Some), - } - }) - .await; - match next { - // The token changed: recheck whether announces now carry it. - None => continue, - Some(None) => return Ok(()), - Some(Some(path)) => { - // Another thread may have set a token after the change check in that same poll. - if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { - check.admit(path); - continue; - } - break path; - } + let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { + return Ok(()); + }; + let set = request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await; + if set || request_token.backs(&path) { + check.admit(path); + continue; } + break path; }; tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); let err = Error::Unauthorized; @@ -2058,6 +2044,68 @@ mod tests { drop(setup); } + /// The publisher's announce cursor can present the token on a broadcast before this + /// cursor reaches it, and the token can be cleared in between: the advertisement still + /// stands on the token it carried, so enforcement admits it rather than close the session. + #[moq_net_sim::test] + async fn a_token_cleared_behind_a_presented_announce_keeps_the_session() { + let auth = crate::auth::Handle::new(true); + let setup = auth.present(bytes::Bytes::new(), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("room/alice").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(false), + ..Default::default() + }); + let request_token = crate::RequestToken::new(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })); + + let mut enforce = std::pin::pin!(enforce_grant( + auth, + origin.consume(), + session, + request_token.clone(), + peer_setup, + Version::Draft18, + )); + assert!(futures::poll!(enforce.as_mut()).is_pending(), "nothing to enforce yet"); + + // Between two polls of this loop: the broadcast appears, the publisher advertises it + // carrying the token, and the token is cleared. + let _bob = origin + .announce("room/bob/cam", crate::origin::Route::default()) + .unwrap(); + let (presented, backing) = request_token.present(&crate::Path::new("room/bob/cam").to_owned()); + assert!( + presented.is_some() && backing.is_some(), + "the advertisement carries the token" + ); + request_token.set(None); + + for _ in 0..ANNOUNCE_TURNS { + assert!( + futures::poll!(enforce.as_mut()).is_pending(), + "enforcement closed over a token-bearing advertisement" + ); + moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; + } + assert!(log.closes().is_empty(), "the session stays open"); + drop(backing); + drop(setup); + } + /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client /// advertise outside its connection grant there: the grant is still enforced. From 4ef59811f519d86a6a6ade46e81c8438b58455a0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 12:23:10 -0700 Subject: [PATCH 25/53] fix(net): a token change reconciles withheld namespaces Setting a request token renewed only live advertisements. A namespace withheld for lack of a grant stayed unadvertised until an unrelated route or grant change, although the token could now carry it. Every token change now reconciles each watched namespace. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 160e333c6e..6a913eebc0 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3272,6 +3272,13 @@ where NamespaceEvent::TokenRefresh(token) => { last_token = token.clone(); self.refresh_request_token(&mut ns, token).await?; + // What the token now carries or no longer carries changes what may be offered: a + // namespace withheld for lack of a grant goes out once a token can carry it. + let suffixes: Vec = ns.watched.keys().cloned().collect(); + for suffix in suffixes { + let path = prefix.join(&suffix); + self.sync_namespace(&mut ns, &suffix, &path).await?; + } } NamespaceEvent::Retry => { retry_at = None; @@ -13136,6 +13143,61 @@ mod tests { ); } + /// A namespace withheld for lack of a grant goes out as soon as a request token is set + /// that can carry it, not on the next unrelated route or grant change. + #[moq_net_sim::test] + async fn setting_a_token_offers_a_withheld_namespace() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + // A connection grant of "other", which does not cover "cam". + 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 { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let request_token = crate::RequestToken::new(None); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(request_token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..80 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "withheld without a token"); + + request_token.set(Some(typed(&token))); + for _ in 0..80 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "the token carries the withheld namespace"); + } + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. fn typed(raw: &[u8]) -> crate::setup::Token { assert_eq!(raw[0], 0x03, "USE_VALUE"); From ce9c3a81da367765082e97713b5db93b7d6bd5e2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 12:57:37 -0700 Subject: [PATCH 26/53] fix(net): an update queued behind a renewal spends its request ID on arrival An update waiting behind a pending renewal verdict spent its request ID only when dequeued. An announce that ended first dropped the queue and left those IDs unused, so the peer could reuse one without INVALID_REQUEST_ID. The ID is now spent when the update arrives, as on the subscription path. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 90 +++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6ebce47ac2..aa8d3a5265 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1500,6 +1500,8 @@ where Renewal(request_update::Verdict), } + // Request IDs of updates queued behind a pending verdict, already spent. + let mut spent = std::collections::HashSet::new(); loop { // Handle one buffered update before reading the next, so answers stay in order. let turn = if let Some((id, body)) = renewal.next() { @@ -1539,6 +1541,12 @@ where 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) => { + // Its request ID is spent on arrival, so one dropped with the queue stays spent. + if id == ietf::PublishNamespaceUpdate::ID && 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); + } match renewal.queue((id, body), self.version) { Ok(()) => continue, // The dispatcher closes only on is_protocol_violation, which excludes @@ -1617,16 +1625,11 @@ where } // 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. + // 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 - && let Err(err) = self.control.accept(update_id) - { - self.session.clone().close( - super::error::to_session_code(&crate::SessionError::from(&err)), - "invalid request ID", - ); - return Err(err); + 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 @@ -1798,6 +1801,18 @@ 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 => { @@ -10015,6 +10030,63 @@ mod tests { ); } + /// 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 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] From b2414f88f5afdad5fac15a34f7413f135317b852 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 13:39:22 -0700 Subject: [PATCH 27/53] fix(net): a subscription's gate follows the token its SUBSCRIBE carried The gate was chosen at admission, so a token set while the SUBSCRIBE waited for a request ID or stream rode the request under the connection grant's gate. Narrowing that grant then cancelled a subscription the peer held on the token. The gate is now chosen from the token actually encoded. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 94 +++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index aa8d3a5265..2e57b5a9f4 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2494,6 +2494,17 @@ where let subscriber_priority = super::priority::to_wire(priority); // What the SUBSCRIBE carries, which a replacement set before SUBSCRIBE_OK must renew. let sent_token = self.request_token.peek(); + // The peer holds the request to the token it carries, or to our grant without one, so + // the gate follows what is sent rather than what admission saw. + let gate_for = match sent_token.is_some() { + true => crate::auth::Gate::limit, + false => crate::auth::Gate::new, + }; + *gate = gate_for( + self.auth.clone(), + broadcast_path.to_owned(), + crate::auth::Direction::Subscribe, + ); let written = async { stream.writer.varint(ietf::Subscribe::ID).await?; stream @@ -8578,6 +8589,89 @@ mod tests { } } + /// A token set after admission but before the SUBSCRIBE goes out rides it, and the + /// subscription is then held to the token, not the connection grant: narrowing that grant + /// does not cancel what the peer admitted on the token. + #[moq_net_sim::test] + async fn a_token_set_before_the_subscribe_goes_out_governs_it() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + let grant = |subtree: &str| crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree(subtree).unwrap().into(), + expires: None, + }; + + let auth = crate::auth::Handle::new(true); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted(0, grant("room")); + let open = kio::Producer::new(false); + let session = crate::lite::test_transport::ScriptedSession::gated_open( + vec![subscribe_ok_bytes(VERSION).await], + open.consume(), + ); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let request_token = crate::RequestToken::new(None); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let serving = moq_net_sim::spawn(async move { + subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; + }); + + // Admitted by the grant, then parked opening its stream while the token is set. + for _ in 0..20 { + settle().await; + } + assert!(log.writes.lock().unwrap().is_empty(), "the SUBSCRIBE was not held"); + request_token.set(Some(typed(&token))); + let Ok(mut opened) = open.write() else { + panic!("stream gate closed"); + }; + *opened = true; + drop(opened); + for _ in 0..200 { + if occurrences(&log, &token) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "the SUBSCRIBE carries the token"); + + auth.granted(0, grant("other")); + for _ in 0..100 { + settle().await; + } + assert!( + !serving.is_finished(), + "narrowing the connection grant cancelled a token-admitted subscription" + ); + + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + } + /// A token-bearing client does not self-censor on its connection grant: the /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the /// token; a token-less client with the same grant rejects it locally, as before. From d9289e21827cd2c5105dfe2a69643d729b535cde Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 13:55:06 -0700 Subject: [PATCH 28/53] test(net): a token cleared before an advertisement goes out withdraws it Covers a token cleared while an advertisement outside the grant waits for its stream. The token-change reconcile withdraws the token-less request once it lands, and offers it again once a token is set. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6a913eebc0..2315c9aaab 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -13198,6 +13198,80 @@ mod tests { assert_eq!(occurrences(&log, &token), 1, "the token carries the withheld namespace"); } + /// A token cleared while an advertisement outside the grant waits for its stream leaves + /// it carrying none: it is withdrawn, and setting a token again offers it afresh. + #[moq_net_sim::test] + async fn a_token_cleared_before_the_advertisement_goes_out_withdraws_it() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + // A connection grant of "other", which does not cover "cam". + 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 { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let ok = publish_namespace_ok(VERSION).await; + let open = kio::Producer::new(false); + let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], open.consume()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let request_token = crate::RequestToken::new(Some(typed(&token))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(request_token.clone()); + + // Offered under the token, then parked opening its stream while the token is cleared. + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert!(log.writes.lock().unwrap().is_empty(), "the advertisement was not held"); + request_token.set(None); + let Ok(mut opened) = open.write() else { + panic!("stream gate closed"); + }; + *opened = true; + drop(opened); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if !log.resets().is_empty() { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement went out"); + assert_eq!(occurrences(&log, &token), 0, "carrying no token"); + assert!(!log.resets().is_empty(), "and was withdrawn"); + + request_token.set(Some(typed(&token))); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "a token set again offers it afresh"); + } + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. fn typed(raw: &[u8]) -> crate::setup::Token { assert_eq!(raw[0], 0x03, "USE_VALUE"); From 70ad5c6ef14fa5587d435b4b8899afaebb38e36e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 15:12:56 -0700 Subject: [PATCH 29/53] fix(net): a queued announce update is decoded on arrival An update queued behind a pending renewal was decoded only when drained, so a peer could send a session-fatal one (an unknown token alias) and then withdraw the announce: the withdrawal dropped the queue and the session stayed up. Decode it as it arrives, leaving a repeated token to refuse only the announce on its turn. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 101 ++++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 70d9a892eb..5c76371ecd 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1541,11 +1541,26 @@ where 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) => { - // Its request ID is spent on arrival, so one dropped with the queue stays spent. - if id == ietf::PublishNamespaceUpdate::ID && 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); + 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 repeated token 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(DecodeError::MalformedAuthToken) => {} + msg => { + msg?; + if !data.is_empty() { + return Err(Error::WrongSize); + } + } + } } match renewal.queue((id, body), self.version) { Ok(()) => continue, @@ -10185,6 +10200,82 @@ mod tests { ); } + /// 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] From f390b5cae91a83b35b5dba594492ddf75e87f82c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 15:54:43 -0700 Subject: [PATCH 30/53] fix(net): a renewal that cannot be sent ends its subscription A replaced request token that could not go out, such as for want of request ID credit, was logged and forgotten: the loop had already recorded it as presented, so nothing retried it and the subscription ran on the old grant until that lapsed. End the subscription with the send's error instead. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 80 +++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 7d8ad7a201..bf8de62a7c 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -407,7 +407,7 @@ impl Handle { /// Set it before running the session's driver to present it on the first request. /// Replacing it later re-presents the new token on every live request as a /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value - /// again sends nothing. Only drafts 14 and 15 cannot renew an advertisement, which has + /// again sends nothing. A subscription whose renewal cannot be sent ends. Only drafts 14 and 15 cannot renew an advertisement, which has /// no update there, so it keeps the token it was offered with. `None` sends nothing /// either: live requests keep the token they last presented, and later ones carry none. /// Independent of the MoQ Auth extension. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5c76371ecd..2e00f745f2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2905,9 +2905,13 @@ where seen_answers = *answers.lock(); } Ok(_) => {} - // A failed send does not end the subscription: it continues on the old - // grant until that lapses, and the next change re-presents the token. - Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + // The token went unpresented, so the subscription ends now rather than + // run on the old grant with nothing left to retry it. + Err(err) => { + tracing::warn!(%err, "failed to re-present the request token"); + let _ = track.clone().abort(err); + break true; + } } } } @@ -2930,7 +2934,11 @@ where .await { Ok(follow_up) => in_flight = Some((token, follow_up)), - Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + Err(err) => { + tracing::warn!(%err, "failed to re-present the request token"); + let _ = track.clone().abort(err); + break true; + } } } } @@ -8537,6 +8545,70 @@ mod tests { } } + /// A replaced token that cannot be presented, for want of request ID credit, ends the + /// subscription instead of leaving it on the old grant with no retry. + #[moq_net_sim::test] + async fn a_renewal_without_request_id_credit_ends_the_subscription() { + const VERSION: Version = Version::Draft14; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(typed(&first))); + // Credit for the SUBSCRIBE alone, none for a renewal. + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(Some(RequestId(2)), false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_request_token(token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let _subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("broadcast"), dynamic, request)); + + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); + + token.set(Some(typed(&second))); + // Through the wait for request ID credit, which gives up after ten seconds. + let mut ended = None; + for _ in 0..30 { + let _ = futures::poll!(run.as_mut()); + if let Poll::Ready(result) = track.poll_complete(&kio::Waiter::noop()) { + ended = result.err(); + break; + } + settle().await; + moq_net_sim::advance(std::time::Duration::from_secs(1)).await; + } + assert!( + matches!(ended, Some(Error::Cancel)), + "a token that cannot be presented ends the subscription: {ended:?}" + ); + assert_eq!(occurrences(&log, &second), 0, "the token never went out"); + } + /// A token replaced while SUBSCRIBE_OK is outstanding still goes out as a renewal: the /// subscription tracks the token its SUBSCRIBE carried, not the one current at SUBSCRIBE_OK. #[moq_net_sim::test] From b9240e488bb78b67cbc9dfbbea6b76e4f4c49acc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 16:49:07 -0700 Subject: [PATCH 31/53] fix(net): a subscription's renewal never holds back its terminals The subscription loop awaited each token renewal inline: first the wait for a request ID, then the write. A narrowed limit, a departed subscriber, or the publisher's PUBLISH_DONE waited behind it, for up to the ten-second credit timeout, or for good when the peer stopped reading the stream. A renewal now goes out in steps the loop polls alongside its terminals. The request ID wait is a future the loop holds across turns, the update is buffered whole once the ID arrives, and the flush drains it as the stream allows. Dropping either step midway tears nothing: no ID is taken until the wait is ready, and the writer keeps a message whole. A subscription that ends with its renewal still stuck behind a peer that stopped reading resets the stream rather than cutting the update off with a FIN. Drafts 14-16 write to the control stream's adapter, which never parks, so only a real stream can stick. `Writer::buffer_message` buffers an IETF message whole, type and all, so a failed encode leaves no partial type ID behind. `encode_message` now uses it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/coding/writer.rs | 14 +- rs/moq-net/src/ietf/subscriber.rs | 357 ++++++++++++++++++++------ rs/moq-net/src/lite/test_transport.rs | 15 +- 3 files changed, 304 insertions(+), 82 deletions(-) diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 037a095676..8785213272 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -224,10 +224,20 @@ impl Writer { } impl Writer { + /// Encode an IETF `Message` into the write buffer whole, `[type_id][size][body]`, to be sent + /// by [`Self::poll_flush`]. An encode error leaves the buffer untouched. + pub fn buffer_message(&mut self, msg: &T) -> Result<(), Error> { + let version = self.version; + self.buffer_with(|w| { + w.varint(T::ID)?; + msg.encode(w, version) + }) + } + /// Encode an IETF `Message` to the stream, writing `[type_id][size][body]`. pub async fn encode_message(&mut self, msg: &T) -> Result<(), Error> { - self.buffer_varint(T::ID)?; - self.encode(msg).await + self.buffer_message(msg)?; + std::future::poll_fn(|cx| self.poll_flush(cx)).await } } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 2e00f745f2..7f535cc289 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -695,6 +695,12 @@ async fn resolve_track_alias( .await } +/// Wait for a request ID to carry a subscription's token renewal. Owns its handles, so the +/// subscription loop can hold the wait across turns and drop it at any of them. +async fn request_credit(control: Control, runtime: crate::time::Clock) -> Result { + control.next_request_id(&runtime).await +} + impl Subscriber where S: crate::transport::poll::Boxable, @@ -2798,6 +2804,10 @@ where /// The publisher answered the renewal in flight (REQUEST_OK / REQUEST_ERROR), so /// a replacement coalesced behind it may now be sent. Answered, + /// The wait for a request ID to carry the pending renewal ended. + Credit(Result), + /// The buffered renewal failed to reach the stream. + Unsent(Error), Done(Result), Fetch(group::Request), } @@ -2808,16 +2818,27 @@ where let demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); - // The request token last presented on this subscription: the one its SUBSCRIBE carried. - let mut last_token = sent_token; + // The client's current request token, and the one last presented on this subscription: + // both start as the token its SUBSCRIBE carried. + let mut last_token = sent_token.clone(); + let mut presented = sent_token; // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement // that arrives while one is in flight is coalesced into `last_token` and sent once // the outstanding one is answered, so the sender never outruns the receiver's // MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7) whatever its value, without // reading it. `in_flight` is the token value awaiting an answer, `None` when nothing // is outstanding. Draft-14 answers no accepted renewal, so it cannot pace on answers - // and re-presents each change directly; it also advertises no credit to exceed. + // and re-presents each change once the last has gone; it also advertises no credit + // to exceed. let throttle = !matches!(self.version, Version::Draft14); + // A renewal goes out in steps polled alongside the loop's terminals, so a revoked grant + // or a departed subscriber never waits behind one, nor on a peer that stopped reading: + // the wait for a request ID (`credit`, for the token in `sending`), then the buffered + // message draining to the stream (`unflushed`). Dropping either step midway tears + // nothing: no ID is taken until the wait is ready, and the writer keeps a message whole. + let mut sending: Option = None; + let mut credit = std::pin::pin!(futures::future::Fuse::terminated()); + let mut unflushed = false; // The token awaiting an answer, and on drafts 15 and 16 the routing that brings the // answer back here. let mut in_flight: Option<(crate::setup::Token, Option)> = None; @@ -2863,6 +2884,17 @@ where if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { return Poll::Ready(End::Renew(token)); } + if sending.is_some() + && let Poll::Ready(id) = waiter.poll_future(credit.as_mut()) + { + return Poll::Ready(End::Credit(id)); + } + if unflushed && let Poll::Ready(flushed) = stream.writer.poll_flush(&mut waiter.context()) { + unflushed = false; + if let Err(err) = flushed { + return Poll::Ready(End::Unsent(err)); + } + } // Only wait on an answer while a renewal is actually outstanding. if throttle && in_flight.is_some() @@ -2878,70 +2910,47 @@ where .await; match end { - // A replaced token is re-presented as a token-only REQUEST_UPDATE; the read - // future above consumes the answer. The subscribe stream's writer is a - // disjoint borrow from its reader, so writing here does not disturb the read. - End::Renew(token) => { - last_token = token.clone(); - // Send only when no renewal is outstanding; otherwise coalesce, leaving the - // newest in `last_token` to go out on End::Answered. A cleared token (`None`) - // is not a renewal and sends nothing. - let send_now = !throttle || in_flight.is_none(); - if send_now && let Some(token) = token { - match self - .send_request_token_update( - &mut stream.writer, - request_id, - subscriber_priority, - renewal_start, - token.clone(), - ) - .await - { - Ok(follow_up) if throttle => { + // The newest token goes out once nothing holds it back, below. + End::Renew(token) => last_token = token, + // The outstanding renewal was answered, so a replacement coalesced behind it + // may go out. + End::Answered => in_flight = None, + // A replaced token is re-presented as a token-only REQUEST_UPDATE, buffered whole + // and flushed by the wait above; the read future consumes the answer. + End::Credit(Ok(update_id)) => { + let token = sending.take().expect("credit without a pending renewal"); + match self.buffer_request_token_update( + &mut stream.writer, + update_id, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) { + Ok(follow_up) => { + presented = Some(token.clone()); + unflushed = true; + if throttle { in_flight = Some((token, follow_up)); // Only an answer that arrives after this send releases the // coalesced follow-up, so a stray earlier answer cannot. seen_answers = *answers.lock(); } - Ok(_) => {} - // The token went unpresented, so the subscription ends now rather than - // run on the old grant with nothing left to retry it. - Err(err) => { - tracing::warn!(%err, "failed to re-present the request token"); - let _ = track.clone().abort(err); - break true; - } } - } - } - End::Answered => { - // The outstanding renewal was answered. If the credential changed while it - // was in flight, present the newest now (a cleared token sends nothing); - // otherwise the publisher already holds the latest. - let was = in_flight.take().map(|(token, _)| token); - if last_token != was - && let Some(token) = last_token.clone() - { - match self - .send_request_token_update( - &mut stream.writer, - request_id, - subscriber_priority, - renewal_start, - token.clone(), - ) - .await - { - Ok(follow_up) => in_flight = Some((token, follow_up)), - Err(err) => { - tracing::warn!(%err, "failed to re-present the request token"); - let _ = track.clone().abort(err); - break true; - } + Err(err) => { + tracing::warn!(%err, "failed to re-present the request token"); + let _ = track.clone().abort(err); + break true; } } } + // The token went unpresented, so the subscription ends now rather than run on + // the old grant with nothing left to retry it. + End::Credit(Err(err)) | End::Unsent(err) => { + tracing::warn!(%err, "failed to re-present the request token"); + let _ = track.clone().abort(err); + break true; + } End::Revoked => { tracing::info!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "subscription no longer authorized"); let _ = track.clone().abort(Error::Unauthorized); @@ -3009,15 +3018,40 @@ where break false; } } + + // Start the next renewal once nothing holds it back: no renewal still waiting for + // its request ID and, where renewals are paced, none awaiting an answer. A cleared + // token (`None`) is not a renewal and sends nothing. + if sending.is_none() + && !(throttle && in_flight.is_some()) + && last_token.is_some() + && last_token != presented + { + sending = last_token.clone(); + credit.set(futures::FutureExt::fuse(request_credit( + self.control.clone(), + self.runtime.clone(), + ))); + } } }; // Clean up let aborted = self.remove_subscribe(request_id).is_none(); + // A renewal still stuck behind a peer that stopped reading would be cut off mid-message + // by a FIN, so the stream is reset instead. Only a real stream can stick: drafts 14-16 + // write to the control stream's adapter, which never parks. + let stuck = + unflushed && std::future::poll_fn(|cx| Poll::Ready(stream.writer.poll_flush(cx).is_pending())).await; + if !idle { // The publisher already ended the request, so a FIN is all we owe it. - stream.writer.finish().ok(); + if stuck { + stream.writer.abort(&Error::Cancel); + } else { + stream.writer.finish().ok(); + } return None; } // What the copy cached goes stale from here. Marked before the cancel, which waits @@ -3026,7 +3060,15 @@ where if !aborted && !revoked { track.set_idle(); } - self.cancel_subscribe(stream, request_id).await; + match stuck { + // Resetting both directions is the cancellation on the drafts where a stream can stick. + true => { + let Stream { writer, mut reader } = stream; + reader.abort(&Error::Cancel); + writer.abort(&Error::Cancel); + } + false => self.cancel_subscribe(stream, request_id).await, + } // A session abort took the copy too, and a revoked grant ended it. if aborted || revoked { return None; @@ -3190,9 +3232,10 @@ where } } - /// Re-present the client's request token on a live subscription as a token-only - /// REQUEST_UPDATE (SUBSCRIBE_UPDATE), so a refreshed credential reaches the publisher - /// before the old grant lapses (MoQ request-token renewal). + /// Buffer a re-presentation of the client's request token on a live subscription as a + /// token-only REQUEST_UPDATE (SUBSCRIBE_UPDATE) carrying `request_id`, so a refreshed + /// credential reaches the publisher before the old grant lapses (MoQ request-token + /// renewal). The caller flushes it. /// /// Token-only: draft-15+ omits the range, priority and forward flag, which an update keeps /// as they are. Draft-14's fields are fixed, so it restates the subscription's own: @@ -3200,15 +3243,15 @@ where /// must not see decrease. The answer, if the version sends one, is read on the /// subscription stream by [`read_publish_done`](Self::read_publish_done); on drafts 15 /// and 16 the returned guard routes it there until dropped. - async fn send_request_token_update( + fn buffer_request_token_update( &self, writer: &mut crate::coding::Writer, + request_id: RequestId, subscription_id: RequestId, subscriber_priority: u8, start: ietf::Location, token: crate::setup::Token, ) -> Result, Error> { - let request_id = self.control.next_request_id(&self.runtime).await?; // Drafts 15 and 16 answer naming the update itself; draft 14 answers no accepted // update, and later drafts answer on the subscription's own stream. let follow_up = matches!(self.version, Version::Draft15 | Version::Draft16) @@ -3219,18 +3262,16 @@ where _ => None, }; let draft14 = self.version == Version::Draft14; - writer - .encode_message(&ietf::SubscribeUpdate { - request_id, - subscription_request_id, - start_location: start, - end_group: 0, - subscriber_priority: draft14.then_some(subscriber_priority), - forward: draft14.then_some(true), - filter: None, - authorization_token: Some(crate::ietf::token::RequestToken(token)), - }) - .await?; + writer.buffer_message(&ietf::SubscribeUpdate { + request_id, + subscription_request_id, + start_location: start, + end_group: 0, + subscriber_priority: draft14.then_some(subscriber_priority), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(crate::ietf::token::RequestToken(token)), + })?; Ok(follow_up) } @@ -8545,6 +8586,164 @@ mod tests { } } + /// A subscriber presenting `first` as its request token, under a limit on the peer that + /// covers "room", for the renewal tests below. + fn renewal_subscriber( + version: Version, + session: crate::lite::test_transport::ScriptedSession, + control: Control, + first: &bytes::Bytes, + ) -> ( + Subscriber, + crate::auth::Handle, + crate::RequestToken, + crate::util::TaskSet, + ) { + let auth = crate::auth::Handle::new(true); + auth.authorize(&renewal_limit("room")); + let (tasks, task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(typed(first))); + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + control, + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth.clone()) + .with_request_token(token.clone()); + (subscriber, auth, token, task_set) + } + + /// A limit on the peer that covers `prefix` both ways. + fn renewal_limit(prefix: &str) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Pattern::subtree(prefix).unwrap().into(), + subscribe: crate::Pattern::subtree(prefix).unwrap().into(), + expires: None, + } + } + + /// A narrowed limit ends a subscription whose renewal is stuck writing to a peer that + /// stopped reading, and the stuck stream is reset rather than cut off mid-message by a FIN. + #[moq_net_sim::test] + async fn a_revocation_ends_a_subscription_whose_renewal_is_stuck_writing() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + let writable = kio::Producer::new(true); + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await) + .with_send_gate(writable.consume()); + let log = session.log.clone(); + let (mut subscriber, auth, token, _task_set) = + renewal_subscriber(VERSION, session, Control::new(None, false), &first); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let _subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); + + // The peer stops reading, then the token is replaced: the renewal parks writing. + let Ok(mut open) = writable.write() else { + panic!("send gate closed"); + }; + *open = false; + drop(open); + token.set(Some(typed(&second))); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, &second), 0, "the renewal is stuck"); + + auth.authorize(&renewal_limit("other")); + let mut ended = false; + for _ in 0..20 { + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "the revocation waited behind the stuck renewal"); + assert!( + matches!( + track.poll_complete(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Unauthorized)) + ), + "the subscription ends unauthorized" + ); + assert!(!log.resets().is_empty(), "the stuck stream is reset"); + } + + /// A narrowed limit ends a subscription whose renewal waits for request ID credit at once, + /// not when the wait gives up. + #[moq_net_sim::test] + async fn a_revocation_ends_a_subscription_whose_renewal_waits_for_credit() { + const VERSION: Version = Version::Draft14; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. + let (mut subscriber, auth, token, _task_set) = + renewal_subscriber(VERSION, session, Control::new(Some(RequestId(4)), false), &first); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let _subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); + + token.set(Some(typed(&second))); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + auth.authorize(&renewal_limit("other")); + let mut ended = None; + for _ in 0..20 { + let _ = futures::poll!(run.as_mut()); + if let Poll::Ready(result) = track.poll_complete(&kio::Waiter::noop()) { + ended = result.err(); + break; + } + settle().await; + } + assert!( + matches!(ended, Some(Error::Unauthorized)), + "the revocation waited for the credit wait to give up: {ended:?}" + ); + assert_eq!(occurrences(&log, &second), 0, "the token never went out"); + } /// A replaced token that cannot be presented, for want of request ID credit, ends the /// subscription instead of leaving it on the old grant with no retry. #[moq_net_sim::test] @@ -8557,12 +8756,12 @@ mod tests { let log = session.log.clone(); let (tasks, _task_set) = crate::util::TaskSet::new(); let token = crate::RequestToken::new(Some(typed(&first))); - // Credit for the SUBSCRIBE alone, none for a renewal. + // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. let mut subscriber = Subscriber::new( crate::time::Clock::sim(), session, crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(Some(RequestId(2)), false), + Control::new(Some(RequestId(4)), false), None, peer::PeerSetup::default(), crate::Hop::new(1).unwrap(), diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 8595999a49..3913395ad4 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -654,6 +654,8 @@ pub struct ScriptedSession { incoming_bidis: Arc>>>, /// Set by [`Self::with_unacked_fin`]. unacked_fin: bool, + /// Set by [`Self::with_send_gate`]. + send_gate: Option>, } impl ScriptedSession { @@ -668,9 +670,17 @@ impl ScriptedSession { incoming_unis: Arc::new(Mutex::new(std::collections::VecDeque::new())), incoming_bidis: Arc::new(Mutex::new(std::collections::VecDeque::new())), unacked_fin: false, + send_gate: None, } } + /// Park writes on every opened stream while `gate` reads false, like a peer that stopped + /// reading and left no flow-control credit. + pub fn with_send_gate(mut self, gate: kio::Consumer) -> Self { + self.send_gate = Some(gate); + self + } + /// 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; @@ -801,7 +811,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()); + let mut send = match &self.send_gate { + Some(gate) => SinkSend::gated(self.log.clone(), gate.clone()), + None => SinkSend::new(self.log.clone()), + }; send.unacked_fin = self.unacked_fin; Poll::Ready(Ok(( send, From 92ea2b76fae01c36eeab98808c532843a86c98a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 18:32:50 -0700 Subject: [PATCH 32/53] fix(net): a renewal reaches the stream as soon as it takes its request ID Drafts 14-16 require request IDs to arrive in order, and the loop left a buffered renewal for a later turn to flush. A group FETCH queued in between could take the next ID and reach the control stream first, which the peer closes the session over. Flush right after buffering: the drafts 14-16 adapter never parks, so the update goes out ahead of any later request, and a real stream that parks still drains alongside the loop's terminals. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 7f535cc289..6fff60e806 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2929,7 +2929,20 @@ where ) { Ok(follow_up) => { presented = Some(token.clone()); - unflushed = true; + // Hand it to the stream before anything else can take a request ID. + // Drafts 14-16 need IDs to arrive in order, and their adapter never + // parks, so this puts the update on the control stream ahead of any + // later request. A real stream that parks drains in the wait above. + unflushed = + match std::future::poll_fn(|cx| Poll::Ready(stream.writer.poll_flush(cx))).await { + Poll::Ready(Ok(())) => false, + Poll::Ready(Err(err)) => { + tracing::warn!(%err, "failed to re-present the request token"); + let _ = track.clone().abort(err); + break true; + } + Poll::Pending => true, + }; if throttle { in_flight = Some((token, follow_up)); // Only an answer that arrives after this send releases the From c9f72ed0f93616214929946d63d4a19a3d19598e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 18:41:46 -0700 Subject: [PATCH 33/53] fix(net): a token refresh answer with trailing bytes is fatal The namespace token refresh decoded a REQUEST_OK or REQUEST_ERROR answer's fields but never checked that the body was used up. Junk inside the declared size was accepted, marking the advertisement as standing on the new token. Close the session with WrongSize, as the other answer decoders do. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index b42d01bb9f..6f78e954ca 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2940,6 +2940,9 @@ where match type_id { ietf::RequestOk::ID => { let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } tracing::debug!(broadcast = %absolute, "request token refreshed"); if let Some(request) = ns.requests.get_mut(&suffix) { request @@ -2949,6 +2952,9 @@ where } ietf::RequestError::ID => { let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } // A refused renewal means the receiver closed its stream, so the announce // cannot stand on the old grant: withdraw it and honor the refusal's retry // interval, exactly as a repriced update does. The retry re-offers it fresh, @@ -13056,6 +13062,72 @@ mod tests { ); } + /// A token refresh answered with a REQUEST_OK that carries junk inside its declared size + /// ends the session, rather than marking the advertisement as standing on the new token. + #[moq_net_sim::test] + async fn a_token_refresh_answer_with_trailing_bytes_is_fatal() { + const VERSION: Version = Version::Draft17; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // The PUBLISH_NAMESPACE is accepted, then the renewal is answered by a REQUEST_OK whose + // declared size covers one byte more than the message. + let ok = publish_namespace_ok(VERSION).await; + assert_eq!( + ok[0], + ietf::RequestOk::ID as u8, + "a one-byte type, then a two-byte size" + ); + let mut padded = ok.clone(); + let size = u16::from_be_bytes([padded[1], padded[2]]) + 1; + padded[1..3].copy_from_slice(&size.to_be_bytes()); + padded.push(0x00); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, padded].concat()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(typed(&first))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); + + token.set(Some(typed(&second))); + 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(Err(Error::WrongSize))), + "an answer with trailing bytes must be fatal, got {result:?}" + ); + } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged /// token: a receiver renewing a token-authorized announce answers a token-bearing update as /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. From 89a7bca398178e120decde3a13c1f3a73ead88a7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 18:58:08 -0700 Subject: [PATCH 34/53] fix(net): a superseded renewal never goes out A renewal waiting for its request ID kept the token it started with. Replacing the token meanwhile sent the stale one first, and clearing it still sent the old token once credit arrived. A renewal now takes the newest token while it waits, and is dropped when the token is cleared or set back to the one presented. No request ID is taken yet, so nothing is lost. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 91 ++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6fff60e806..47c0ce81c2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2910,8 +2910,22 @@ where .await; match end { - // The newest token goes out once nothing holds it back, below. - End::Renew(token) => last_token = token, + // The newest token goes out once nothing holds it back, below. A renewal still + // waiting for its request ID takes the newest instead, or is dropped when the + // token was cleared or set back to the one presented: no ID is taken yet, so + // a superseded token never reaches the wire. + End::Renew(token) => { + last_token = token; + if sending.is_some() { + match last_token.is_some() && last_token != presented { + true => sending = last_token.clone(), + false => { + sending = None; + credit.set(futures::future::Fuse::terminated()); + } + } + } + } // The outstanding renewal was answered, so a replacement coalesced behind it // may go out. End::Answered => in_flight = None, @@ -8757,6 +8771,79 @@ mod tests { ); assert_eq!(occurrences(&log, &second), 0, "the token never went out"); } + + /// A token replaced or cleared while its renewal waits for a request ID never reaches the + /// wire: the renewal carries the newest token, or is dropped once the token is cleared. + #[moq_net_sim::test] + async fn a_superseded_renewal_never_goes_out() { + const VERSION: Version = Version::Draft14; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let stale = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + let newest = bytes::Bytes::from_static(&[0x03, 0x00, b'c', b'c']); + let cleared = bytes::Bytes::from_static(&[0x03, 0x00, b'd', b'd']); + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. + let (mut subscriber, _auth, token, _task_set) = + renewal_subscriber(VERSION, session, Control::new(Some(RequestId(4)), false), &first); + let control = subscriber.control.clone(); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let _subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); + + // Replaced while waiting for credit: only the newest goes out once credit arrives. + token.set(Some(typed(&stale))); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + token.set(Some(typed(&newest))); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + control.max_request_id(RequestId(6)); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &newest) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &newest), 1, "the newest token goes out"); + assert_eq!(occurrences(&log, &stale), 0, "the superseded token never does"); + + // Cleared while waiting for credit: nothing goes out once credit arrives. + token.set(Some(typed(&cleared))); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + token.set(None); + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + control.max_request_id(RequestId(8)); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, &cleared), 0, "a cleared token never goes out"); + } /// A replaced token that cannot be presented, for want of request ID credit, ends the /// subscription instead of leaving it on the old grant with no retry. #[moq_net_sim::test] From 7f41c3ad26684f28f69975f07e6d5a8600cb54c5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 19:24:30 -0700 Subject: [PATCH 35/53] fix(net): a legacy withdrawal behind an update ends a waiting request On drafts 14-16, a request still waiting for its token to verify watched only the first queued message. An update parked there hid an UNSUBSCRIBE or PUBLISH_NAMESPACE_DONE behind it, so a withdrawn request held its verdict and slot until verification finished. Peek past whole updates to the first message that ends the request, leaving the updates queued for once the request is answered. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/request_stream.rs | 23 +++++++---- rs/moq-net/src/ietf/subscriber.rs | 55 +++++++++++++++------------ 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 8939badac7..5ef5dfbca1 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -47,14 +47,11 @@ pub(super) fn poll_withdrawn( 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. + // the FIN, so a waiting message that is not an update already withdraws the request, even + // behind updates. 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, - }; + return stream.reader.poll_decode_peek_maybe::(cx).map(|_| ()); } if stream.writer.poll_closed(cx).is_ready() { return Poll::Ready(()); @@ -106,6 +103,18 @@ impl Decode for FollowUp { } } +/// The first draft 14-16 message that ends the request, found by skipping whole updates. Only +/// peeked, so the updates stay queued for once the request is answered. +#[derive(Debug)] +struct LegacyEnd; + +impl Decode for LegacyEnd { + fn decode(r: &mut Decoder<'_>, version: Version) -> Result { + while let FollowUp::Update(_) = FollowUp::decode(r, version)? {} + Ok(Self) + } +} + /// One whole `[type][length][body]` message off a request stream, decoded at once so a read /// dropped part-way consumes nothing. #[derive(Debug)] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 47c0ce81c2..943359fece 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -9444,7 +9444,8 @@ mod tests { /// 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. + /// once, even queued behind an update, 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; @@ -9459,33 +9460,37 @@ mod tests { .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, - )); + // An empty REQUEST_UPDATE frame: type, then a zero length. + let update = vec![ietf::SubscribeUpdate::ID as u8, 0, 0]; + for (case, script) in [("alone", done.clone()), ("behind an update", [update, done].concat())] { + // 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, script); + 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; + let mut result = None; + for _ in 0..100 { + if let Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; + } + settle().await; } - settle().await; + assert!( + matches!(result, Some(Ok(()))), + "{case}: a withdrawal during verification ends the announce: {result:?}" + ); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "{case}: and it was never attached" + ); + assert!(session.log.closes().is_empty(), "{case}: the session stays up"); } - 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 From f9dacb35a6ae33326a267b8ef7c8a217a4bb648a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 20:07:43 -0700 Subject: [PATCH 36/53] revert(net): keep a pending update pausing the withdrawal watch This reverts 7f41c3ad2. Peeking past queued updates skipped them as opaque bodies, so a malformed update followed by a withdrawal was dropped instead of closing the session, and every poll rescanned the whole backlog from the start. Validating them while verification waits would need a drain queue that both admission paths consume. A waiting update pausing the watch is the documented contract, and drafts 17/18 behave the same way: until the verifier answers, the peer holds only its own slot, and every update is still decoded in order once the request is answered. Say so on poll_withdrawn. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/request_stream.rs | 27 +++++-------- rs/moq-net/src/ietf/subscriber.rs | 55 ++++++++++++--------------- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 5ef5dfbca1..3505b7933f 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -40,18 +40,23 @@ pub(super) fn poll_cancel( /// 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. +/// 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, and every +/// update is still decoded and validated in order. 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, even - // behind updates. Their virtual writers are closed while the control request lives, so only - // the reader counts. + // 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 stream.reader.poll_decode_peek_maybe::(cx).map(|_| ()); + 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(()); @@ -103,18 +108,6 @@ impl Decode for FollowUp { } } -/// The first draft 14-16 message that ends the request, found by skipping whole updates. Only -/// peeked, so the updates stay queued for once the request is answered. -#[derive(Debug)] -struct LegacyEnd; - -impl Decode for LegacyEnd { - fn decode(r: &mut Decoder<'_>, version: Version) -> Result { - while let FollowUp::Update(_) = FollowUp::decode(r, version)? {} - Ok(Self) - } -} - /// One whole `[type][length][body]` message off a request stream, decoded at once so a read /// dropped part-way consumes nothing. #[derive(Debug)] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 943359fece..47c0ce81c2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -9444,8 +9444,7 @@ mod tests { /// 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, even queued behind an update, giving its slot back before the application ever - /// takes the token. + /// 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; @@ -9460,37 +9459,33 @@ mod tests { .unwrap(); log.writes.lock().unwrap().clone() }; - // An empty REQUEST_UPDATE frame: type, then a zero length. - let update = vec![ietf::SubscribeUpdate::ID as u8, 0, 0]; - for (case, script) in [("alone", done.clone()), ("behind an update", [update, done].concat())] { - // 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, script); - 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, - )); + // 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; + let mut result = None; + for _ in 0..100 { + if let Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; } - assert!( - matches!(result, Some(Ok(()))), - "{case}: a withdrawal during verification ends the announce: {result:?}" - ); - assert!( - routed_now(&consumer, "room/alice").is_none(), - "{case}: and it was never attached" - ); - assert!(session.log.closes().is_empty(), "{case}: the session stays up"); + 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 From cf9e959a001d24cec7ab46a4416ed15497ddfd13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 20:19:12 -0700 Subject: [PATCH 37/53] docs(net): poll_withdrawn claims no validation it does not do A request abandoned before it is answered drops what it left unread, as it does after admission, so the comment no longer promises that every queued update is validated. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/request_stream.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 3505b7933f..1d0466154f 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -41,8 +41,7 @@ pub(super) fn poll_cancel( /// 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, and every -/// update is still decoded and validated in order. +/// only then; until the verifier answers, the requester holds nothing but its own slot. pub(super) fn poll_withdrawn( stream: &mut Stream, version: Version, From 4a290b63b5c4f4c85b32d790e24240e4e9de8ce4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 14 Oct 2026 20:43:44 -0700 Subject: [PATCH 38/53] fix(net): a request token never authorizes a TRACK_STATUS Demand with nobody subscribing learns the track from TRACK_STATUS on drafts that describe the track there, then fetches standalone. Neither carries the request token, yet admission still let the token stand in for the connection grant and gated the track on the limit alone. Grant the token's exemption only when the demand SUBSCRIBEs, so fetch-only demand outside the grant is refused here instead of sent without it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 93 +++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 47c0ce81c2..290ed147cd 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2367,8 +2367,14 @@ where // a token-bearing client does not self-censor on its connection grant: the token // authorizes what that grant does not cover, and the server's covers-gate is the // authority. The token stands in for the grant only, never for the limit this side set - // on the peer, which still filters and, narrowing, revokes. - let token_authorized = self.request_token.peek().is_some(); + // on the peer, which still filters and, narrowing, revokes. Demand with nobody + // subscribing (fetches, or a TRACK_STATUS downstream) learns the track from TRACK_STATUS + // rather than a SUBSCRIBE: a finished track refuses one, and one made only to learn the + // track races the fetches it was for. Neither carries the token, so that demand needs + // the grant. Draft-17 still SUBSCRIBEs: its TRACK_STATUS answer cannot say whether the + // track is timed. + let status = request.subscription().is_none() && ietf::TrackStatusOk::describes_track(self.version); + let token_authorized = !status && self.request_token.peek().is_some(); let allowed = match token_authorized { true => self .auth @@ -2395,27 +2401,21 @@ where // Group FETCHes for cache misses: standalone, so they outlive each subscription. let mut group_fetches = TaskSet::owned(); - // Demand with nobody subscribing (fetches, or a TRACK_STATUS downstream) learns the - // track from TRACK_STATUS rather than a SUBSCRIBE: a finished track refuses one, and - // one made only to learn the track races the fetches it was for. Draft-17 still - // SUBSCRIBEs: its TRACK_STATUS answer cannot say whether the track is timed. - let mut target = match request.subscription() { - None if ietf::TrackStatusOk::describes_track(self.version) => { - match self.track_status(&broadcast_path, &track_name, request).await { - Some(Target::Resume(idle)) => { - let Some(next) = self - .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) - .await - else { - return; - }; - Target::Resume(next) - } - Some(target) => target, - None => return, + let mut target = match status { + true => match self.track_status(&broadcast_path, &track_name, request).await { + Some(Target::Resume(idle)) => { + let Some(next) = self + .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) + .await + else { + return; + }; + Target::Resume(next) } - } - _ => Target::Request(request), + Some(target) => target, + None => return, + }, + false => Target::Request(request), }; loop { let Some(idle) = self @@ -8657,6 +8657,55 @@ mod tests { } } + /// A request token rides only on SUBSCRIBE, so demand that learns the track from + /// TRACK_STATUS, with nobody subscribing, needs the connection grant: outside it, the + /// request is refused here rather than sent without the token. + #[moq_net_sim::test] + async fn a_request_token_never_authorizes_a_track_status() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); + let log = session.log.clone(); + let (mut subscriber, auth, _token, _task_set) = + renewal_subscriber(VERSION, session, Control::new(None, false), &first); + // The connection grant covers only "other". + 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, + }, + ); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let mut query = std::pin::pin!(track.query()); + assert!(futures::poll!(query.as_mut()).is_pending()); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); + let mut ended = false; + for _ in 0..50 { + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "fetch-only demand outside the grant waits on the peer"); + assert!( + matches!(query.await, Err(Error::Unauthorized)), + "fetch-only demand outside the grant is refused" + ); + assert!( + control_message_types(&log, VERSION).is_empty(), + "nothing goes out without the token" + ); + } + /// A narrowed limit ends a subscription whose renewal is stuck writing to a peer that /// stopped reading, and the stuck stream is reset rather than cut off mid-message by a FIN. #[moq_net_sim::test] From 560ac77ff5eff0ca278fbe822f226872b9dcce19 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 07:13:11 -0700 Subject: [PATCH 39/53] fix(net): verify every request token, and refuse several on one request Every request that carries an AUTHORIZATION TOKEN is now verified, even where this side's limit already covers it. Admitting a covered token unread let the peer believe its token backed a request held to the limit, so narrowing the limit withdrew an announce the peer still counted as token-backed. Without a verifier such a request is refused NOT_SUPPORTED, which moq-relay now does for every token-carrying request. The sender renews only requests that carried a token, and a receiver whose tokens decide refuses a token arriving on an update for a request admitted without one. A request or update with more than one distinct token is refused NOT_SUPPORTED (DecodeError::MultipleAuthTokens) instead of keeping the first; a repeat stays MALFORMED_AUTH_TOKEN. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 5 +- doc/concept/standard.md | 2 +- rs/moq-net/src/auth.rs | 50 ++-- rs/moq-net/src/coding/decode.rs | 5 + rs/moq-net/src/ietf/error.rs | 5 +- rs/moq-net/src/ietf/fetch.rs | 6 +- rs/moq-net/src/ietf/publish.rs | 4 +- rs/moq-net/src/ietf/publish_namespace.rs | 8 +- rs/moq-net/src/ietf/publisher.rs | 310 ++++++++++++++++----- rs/moq-net/src/ietf/request_stream.rs | 31 ++- rs/moq-net/src/ietf/subscribe.rs | 49 ++-- rs/moq-net/src/ietf/subscribe_namespace.rs | 4 +- rs/moq-net/src/ietf/subscriber.rs | 213 +++++++++++--- rs/moq-net/src/ietf/token.rs | 53 +++- rs/moq-net/tests/auth.rs | 5 +- 15 files changed, 557 insertions(+), 193 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 90018cfcd3..83e4923665 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -143,7 +143,10 @@ 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. The relay does +not verify a moq-transport `AUTHORIZATION TOKEN` on a `SUBSCRIBE` or +`PUBLISH_NAMESPACE` either, so it refuses that request as unsupported, even one +the connection's grant covers; send the credential at connect time instead. 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 36848807df..5bf8677079 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** 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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when our own authorization of the peer does not cover it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. +- **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified and authorizes that one request, even where our own authorization of the peer already covers it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index bf8de62a7c..7fb297f759 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -405,11 +405,12 @@ impl Handle { /// peer verifies this one per request. Experimental. /// /// Set it before running the session's driver to present it on the first request. - /// Replacing it later re-presents the new token on every live request as a - /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value - /// again sends nothing. A subscription whose renewal cannot be sent ends. Only drafts 14 and 15 cannot renew an advertisement, which has - /// no update there, so it keeps the token it was offered with. `None` sends nothing - /// either: live requests keep the token they last presented, and later ones carry none. + /// Replacing it later re-presents the new token on every live request that carried one, + /// as a REQUEST_UPDATE renewing it in place; a request sent without one stays without. + /// Setting the same value again sends nothing. A subscription whose renewal cannot be + /// sent ends. Only drafts 14 and 15 cannot renew an advertisement, which has no update + /// there, so it keeps the token it was offered with. `None` sends nothing either: live + /// requests keep the token they last presented, and later ones carry none. /// Independent of the MoQ Auth extension. /// /// While a token is set, this side stops closing the session over a broadcast its own @@ -534,11 +535,12 @@ impl Handle { } /// Verify the `AUTHORIZATION TOKEN` on each of the peer's SUBSCRIBE and PUBLISH_NAMESPACE - /// requests that [`authorize`](Self::authorize) does not cover. Experimental. + /// requests, even one [`authorize`](Self::authorize) covers. Experimental. /// /// Each token decides its request alone, with a grant that never joins the session's. - /// Without this, such a request is refused as unsupported, and a session never - /// authorized admits every request without reading its token. Take it before running the + /// Without this, a request carrying one is refused as unsupported, and a session never + /// authorized admits every request without reading its token. A request or update + /// carrying more than one token is refused as unsupported. Take it before running the /// session's driver, so no request is admitted before it. Independent of the MoQ Auth /// extension, since the token rides the request itself. /// @@ -713,8 +715,8 @@ impl Handle { /// 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 request outside it that carries its own token is decided by that token through - /// [`scoped`](Self::scoped), or refused as unsupported when nothing verifies them. + /// A request that carries its own token is decided by that token, inside it or not, + /// through [`scoped`](Self::scoped), or refused as unsupported when nothing verifies them. /// /// A narrower grant ends what falls outside at once, with [`Error::Unauthorized`] /// where it has a reader: announcements to the peer retract, its new requests are @@ -777,18 +779,17 @@ impl Handle { self.state.read().allows(direction, path) } - /// Whether a request token decides a request at `path`, rather than this side's own - /// authorization of the peer: the limit does not cover it, so the application verifies the - /// token, or the request is refused as unsupported without a verifier. A session never - /// limited asks only an application that verifies request tokens, and admits otherwise. + /// Whether a request token decides the request carrying it, rather than this side's own + /// authorization of the peer: the application verifies every token, or the request is + /// refused as unsupported without a verifier, even where the limit already covers it. A + /// token admitted unread would leave the peer believing it backs a request this side holds + /// to its limit. A side that neither limits the peer nor verifies 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, path: &str) -> bool { + pub(crate) fn token_decides(&self, direction: Direction) -> bool { let state = self.state.read(); let (_, limit) = state.parts(direction); - match limit { - Some(limit) => !limit.matches(path), - None => state.verifier.is_some(), - } + limit.is_some() || state.verifier.is_some() } /// Whether the peer's grant alone allows `direction` at `path`, allowing everything @@ -2010,7 +2011,7 @@ mod tests { value: b"jwt".to_vec(), }; assert!( - !handle.token_decides(Direction::Publish, "room"), + !handle.token_decides(Direction::Publish), "a session never limited and with no verifier ignores the token" ); let verdict = handle.verify_request(token(), path(), RequestKind::Subscribe, None); @@ -2024,7 +2025,14 @@ mod tests { let _scoped = handle.scoped().unwrap(); assert!(matches!(handle.scoped(), Err(Error::Duplicate))); - assert!(handle.token_decides(Direction::Publish, "room")); + assert!(handle.token_decides(Direction::Publish)); + + let limited = Handle::new(true); + limited.authorize(&Grant::all()); + assert!( + limited.token_decides(Direction::Publish), + "a limit, even one covering everything, leaves the token to decide" + ); let lite = Handle::new(false); lite.lite(); 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/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/publish.rs b/rs/moq-net/src/ietf/publish.rs index a65e9fade4..d01d5ce4f2 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,7 +349,7 @@ 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)?; + super::token::single(authorization_token)?; let mut properties = Properties::decode(r, version)?; properties.group_order = properties.group_order.or(group_order); diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 1c93b8a6be..8ea2fe4793 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -47,7 +47,7 @@ impl PublishNamespace<'_> { cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - super::token::unique(&authorization_token)?; + let authorization_token = super::token::single(authorization_token)?; let cluster = match negotiated { true => Some(cluster::Advert { @@ -64,7 +64,7 @@ impl PublishNamespace<'_> { request_id, track_namespace, cluster, - authorization_token: authorization_token.into_iter().next(), + authorization_token, }) } } @@ -182,13 +182,13 @@ impl Message for PublishNamespaceUpdate { cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - super::token::unique(&authorization_token)?; + let authorization_token = super::token::single(authorization_token)?; Ok(Self { request_id, namespace_request_id, hops, cost, - authorization_token: authorization_token.into_iter().next(), + authorization_token, }) } } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6f78e954ca..1d3926497b 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -600,8 +600,8 @@ 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) => { + return Ok(this.reject_token(stream, request::Kind::Subscribe, body.clone(), err, serve)); } msg => msg?, }; @@ -620,8 +620,8 @@ 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) => { + return Ok(this.reject_token(stream, request::Kind::Fetch, body.clone(), err, serve)); } msg => msg?, }; @@ -655,9 +655,9 @@ where }) }; let (msg, options) = match decoded { - Err(DecodeError::MalformedAuthToken) => { + Err(err) if super::token::refuses_request(&err) => { 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?, }; @@ -701,8 +701,8 @@ 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) => { + return Ok(this.reject_token(stream, request::Kind::TrackStatus, body.clone(), err, serve)); } msg => msg?, }; @@ -732,12 +732,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"); } } @@ -762,15 +763,16 @@ 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. Our own authorization of the peer (the limit) admits the request; when it - // does not cover it, an AUTHORIZATION TOKEN on the request goes to the application - // to verify for this request alone (MoQ request-token). What the peer granted us - // bounds what we send either way, and never stands in for our authorization of it. + // 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, path) => + && self.auth.token_decides(crate::auth::Direction::Publish) => { Some(token.0.clone()) } @@ -1330,11 +1332,24 @@ where // update's own Request ID; later drafts name none. let answer = self.version != Version::Draft14; let failed = |err| Served::Track(Err(err)); - if let Some(err) = update.refused { - let reason = match err { - Error::Unsupported => "REQUEST_UPDATE parameters not supported", - _ => "repeated AUTHORIZATION TOKEN", - }; + // 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 @@ -2877,7 +2892,14 @@ where return Ok(()); } - let suffixes: Vec = ns.requests.keys().cloned().collect(); + // Only an advertisement that carried a token stands on one: the rest stand on the + // connection grant, and a token arriving on them would be refused unread. + let suffixes: Vec = ns + .requests + .iter() + .filter(|(_, request)| request.backing.is_some()) + .map(|(suffix, _)| suffix.clone()) + .collect(); for suffix in suffixes { let request_id = self.control.next_request_id(&self.runtime).await?; let Some(request) = ns.requests.get_mut(&suffix) else { @@ -5857,10 +5879,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); @@ -5872,23 +5895,30 @@ 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"); + + 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. - #[moq_net_sim::test] - async fn a_repeated_update_token_is_malformed() { - for version in [Version::Draft16, Version::Draft22] { + /// NOT_SUPPORTED, and one with several distinct tokens NOT_SUPPORTED. Either fails only its + /// subscription. + #[moq_net_sim::test] + 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(), @@ -5899,12 +5929,21 @@ mod serve_tests { None, ); let mut finished = false; - let served = h - .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}, None) - .await; + 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(Served::UpdateFailed(Error::Unsupported))), + matches!(served, Some(Some(Served::UpdateFailed(Error::Unsupported)))), "{version}: {served:?}" ); assert!(h.log.closes().is_empty(), "{version}: the session closed"); @@ -5913,7 +5952,7 @@ mod serve_tests { 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}"); } } @@ -10372,43 +10411,128 @@ mod serve_tests { assert!(ended, "the refused subscription ends"); } - /// A limit that covers the SUBSCRIBE admits it on its own: the token rides along unread. + /// 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_admits_without_the_verifier() { - let room = crate::auth::Grant { + async fn a_covering_limit_still_verifies_the_token() { + let limit = |subtree: &str| crate::auth::Grant { publish: crate::Patterns::new(), - subscribe: crate::Pattern::subtree("room").unwrap().into(), + subscribe: crate::Pattern::subtree(subtree).unwrap().into(), expires: None, }; - let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::default(), Some(room)).await; - assert!(still_serving(&mut serving).await, "the limit admits the subscription"); - drop(auth); + 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" + ); } - /// With no verifier, a token the limit leaves to decide refuses the request as unsupported - /// rather than unauthorized, and a session never limited ignores the token. + /// 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; - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); - let h = serve_with_auth(VERSION, auth, Vec::new()); + 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 res = h - .publisher - .clone() - .run_subscribe_stream(stream, token_subscribe()) - .await; - assert!(res.is_ok(), "refused, not failed: {res:?}"); + 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); + 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), + error_code: request::to_code(&Error::Unsupported, request::Kind::Subscribe, version), reason_phrase: Error::Unsupported.to_string().into(), retry_interval: 0, }) @@ -10417,21 +10541,6 @@ mod serve_tests { log.writes.lock().unwrap().clone() }; assert_eq!(occurrences(&h.log, &refusal), 1, "refused NOT_SUPPORTED"); - - 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; - } } /// A token waiting on the application holds the subscription's slot. Withdrawn while still @@ -12633,6 +12742,57 @@ mod tests { assert!(renewed, "a replaced token must be re-presented on the live announce"); } + /// A namespace advertised without a token stands on the connection grant, so setting a + /// token later re-presents nothing on it: the receiver would refuse a token on an + /// advertisement it admitted without one. + #[moq_net_sim::test] + async fn a_token_set_later_never_renews_a_tokenless_announce() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let request_token = crate::RequestToken::new(None); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(request_token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut announced = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if !log.writes.lock().unwrap().is_empty() { + announced = true; + break; + } + settle().await; + } + assert!(announced, "the namespace is advertised without a token"); + + request_token.set(Some(typed(&token))); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, &token), 0, "the token renewed a tokenless announce"); + } + /// A token replaced while the initial PUBLISH_NAMESPACE awaits its answer is re-presented /// once that answer arrives, rather than counted as the token the announce carried. #[moq_net_sim::test] diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index 1d0466154f..c277431314 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -125,8 +125,9 @@ 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, @@ -161,11 +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), - authorization_token: tokens.into_iter().next(), + refused, + authorization_token, }); } // The request it updates, which the adapter already routed by. @@ -206,20 +208,25 @@ impl Update { || !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), - authorization_token: token.into_iter().next(), + 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/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index a8035631fb..3c3b16c361 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -75,8 +75,7 @@ impl Message for Subscribe<'_> { let filter = Filter::decode(r, version)?; let tokens = Parameters::skip_request(r)?; - super::token::unique(&tokens)?; - let authorization_token = tokens.into_iter().next(); + let authorization_token = super::token::single(tokens)?; Ok(Self { request_id, @@ -114,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, @@ -150,7 +149,7 @@ impl Message for Subscribe<'_> { // Absent means 1. forward: forward.unwrap_or(true), range_filters, - authorization_token: authorization_token.into_iter().next(), + authorization_token, }) } } @@ -460,8 +459,7 @@ impl Message for SubscribeUpdate { let subscriber_priority = Some(r.u8()?); let forward = Some(r.bool()?); let tokens = Parameters::skip_request(r)?; - super::token::unique(&tokens)?; - let authorization_token = tokens.into_iter().next(); + let authorization_token = super::token::single(tokens)?; Ok(Self { request_id, @@ -494,7 +492,7 @@ impl Message for SubscribeUpdate { subscriber_priority, forward, filter, - authorization_token: authorization_token.into_iter().next(), + authorization_token: super::token::single(authorization_token)?, }) } _ => { @@ -529,7 +527,7 @@ impl Message for SubscribeUpdate { subscriber_priority, forward, filter, - authorization_token: authorization_token.into_iter().next(), + authorization_token: super::token::single(authorization_token)?, }) } } @@ -1322,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}" + ); } } diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 1fc96af1fc..6ce715e0b6 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -79,7 +79,7 @@ impl Message for SubscribeNamespace<'_> { 0x03 => authorization_token: Vec, HIDDEN_PARAM => hidden: Option, ); - super::token::unique(&authorization_token)?; + super::token::single(authorization_token)?; Ok(Self { request_id, @@ -181,7 +181,7 @@ 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)?; + super::token::single(authorization_token)?; Ok(Self { request_id, diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 290ed147cd..a5ab5267d1 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1180,8 +1180,8 @@ 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) => { + return Ok(this.reject_token(stream, request::Kind::Publish, body.clone(), err)); } msg => msg?, }; @@ -1200,8 +1200,8 @@ 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) => { + return Ok(this.reject_token(stream, request::Kind::PublishNamespace, body.clone(), err)); } msg => msg?, }; @@ -1241,10 +1241,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"); } } @@ -1313,18 +1314,13 @@ where return Ok(()); }; - // Our authorization of the peer (the limit) admits the announce, and holds back one - // it does not cover until it does. When it does not cover it, an AUTHORIZATION TOKEN - // on the announce goes to the application to verify for this announce alone (MoQ - // request-token), and a verified one is exempt from the limit while it lasts. + // 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, path.as_str()) => - { - Some(token.0.clone()) - } + Some(token) if self.auth.token_decides(crate::auth::Direction::Subscribe) => Some(token.0.clone()), _ => None, }; let (admit, token_grant) = match token { @@ -1555,11 +1551,11 @@ where spent.insert(update_id); } // Decoded on arrival too, so a session-fatal update still closes the session - // when a withdrawal drops the queue. A repeated token only refuses this + // 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(DecodeError::MalformedAuthToken) => {} + Err(err) if super::token::refuses_request(&err) => {} msg => { msg?; if !data.is_empty() { @@ -1653,12 +1649,12 @@ where 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) => { + // A token refusal rejects the update, which withdraws the advertisement as + // any refused update does. + Err(err) if super::token::refuses_request(&err) => { let refusal = crate::auth::Refusal { - error: Error::Decode(DecodeError::MalformedAuthToken), - reason: "repeated AUTHORIZATION TOKEN".to_string(), + reason: super::token::reason(&err).to_string(), + error: Error::Decode(err), }; return Ok(Withdraw::Cancel(update_id, refusal)); } @@ -1732,11 +1728,23 @@ where // 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 - && let Some(grant) = &token_grant - { - renewal.verify(grant.verify_renewal(&self.auth, token.0), msg.request_id); - continue; + 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; + } + // 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. @@ -3048,9 +3056,11 @@ where // Start the next renewal once nothing holds it back: no renewal still waiting for // its request ID and, where renewals are paced, none awaiting an answer. A cleared - // token (`None`) is not a renewal and sends nothing. + // token (`None`) is not a renewal and sends nothing, and a subscription whose + // SUBSCRIBE carried no token stands on the connection grant, with no token to renew. if sending.is_none() && !(throttle && in_flight.is_some()) + && presented.is_some() && last_token.is_some() && last_token != presented { @@ -8821,6 +8831,64 @@ mod tests { assert_eq!(occurrences(&log, &second), 0, "the token never went out"); } + /// A subscription whose SUBSCRIBE carried no token stands on the connection grant, so a + /// token set later renews nothing on it: the receiver would refuse a token on a request it + /// admitted without one. + #[moq_net_sim::test] + async fn a_token_set_later_never_renews_a_tokenless_subscription() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let request_token = crate::RequestToken::new(None); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_request_token(request_token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let _subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if !control_message_types(&log, VERSION).is_empty() { + break; + } + settle().await; + } + assert_eq!( + control_message_types(&log, VERSION).first(), + Some(&ietf::Subscribe::ID), + "the SUBSCRIBE goes out" + ); + + request_token.set(Some(typed(&token))); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log, &token), + 0, + "the token renewed a tokenless subscription" + ); + } + /// A token replaced or cleared while its renewal waits for a request ID never reaches the /// wire: the renewal carries the newest token, or is dropped once the token is cleared. #[moq_net_sim::test] @@ -9621,17 +9689,33 @@ mod tests { assert!(routed_now(&consumer, "room/alice").is_none(), "and is never attached"); } - /// A limit that covers the announce admits it on its own: the token rides along unread. + /// 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_admits_without_the_verifier() { + async fn a_covering_limit_still_verifies_the_token() { const VERSION: Version = Version::Draft18; - let limit = crate::auth::Grant { - publish: crate::Pattern::subtree("room").unwrap().into(), + let limit = |subtree: &str| crate::auth::Grant { + publish: crate::Pattern::subtree(subtree).unwrap().into(), subscribe: crate::Patterns::new(), expires: None, }; - let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION, Some(limit)); + 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(), @@ -9640,6 +9724,7 @@ mod tests { )); 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; @@ -9647,10 +9732,22 @@ mod tests { } settle().await; } - assert!(routed, "the limit admits the announce"); + 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!( - requests.poll_next(&kio::Waiter::noop()).is_pending(), - "the token never reached the verifier" + routed_now(&consumer, "room/alice").is_some(), + "narrowing the limit withheld the token-verified announce" ); } @@ -9969,6 +10066,48 @@ mod tests { } } + /// 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 diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 74b8713774..0860c517b6 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -68,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`. @@ -91,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)?; @@ -104,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?; @@ -178,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) )); } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index d69e87b0ab..45fbd9acf8 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -596,9 +596,8 @@ async fn a_request_token_renews_a_legacy_announce_through_the_driver() { /// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so /// the server sees `declared.auth == false` and the session carries no connection grant /// (`None` union). A request-borne `AUTHORIZATION TOKEN` is then the authorizing artifact -/// and reaches the server's acceptor, where a normal draft-18 client's connection grant -/// would cover the request and skip the token. A token-less request on such a -/// session is admitted by the permissive default of the ungranted session. +/// and reaches the server's acceptor. A token-less request on such a session is admitted by +/// the permissive default of the ungranted session. /// /// The token is exercised on a SUBSCRIBE, which is always a request and carries the token /// on every draft. A request token on a PUBLISH_NAMESPACE does NOT reach a moq-net peer at From 3b3575d928da72687dad2c0bd7e896c7d9a362df Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 07:33:06 -0700 Subject: [PATCH 40/53] docs(net): Scoped receives every request token, covered or not Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 7fb297f759..4fbc7eb24a 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1308,8 +1308,8 @@ impl RequestKind { /// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, waiting for an answer. Experimental. /// -/// The session asks only when its own authorization of the peer does not cover the request, -/// and the application verifies the token: carrying one proves nothing. The grant must cover +/// The session asks for every request carrying a token, even one its own authorization of +/// the peer already covers, and the application verifies the token: carrying one proves nothing. The grant must cover /// the request, never joins the session's grant, and ends with the request. /// /// Dropping it unanswered refuses the request with [`SessionError::Unauthorized`]. From ed595beefce6d8ac988131d0c31724440097ada8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 07:59:31 -0700 Subject: [PATCH 41/53] fix(tokio): renew the request token on sessions still draining A GOAWAY migration keeps the predecessor serving its requests while the replacement takes over, so a token set meanwhile now reaches both until the predecessor ends. Also corrects the token field docs, which still claimed a repeated token kept the first. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publish_namespace.rs | 8 +- rs/moq-net/src/ietf/subscribe.rs | 8 +- rs/moq-tokio/src/client.rs | 2 +- rs/moq-tokio/src/connection/auth.rs | 115 ++++++++++++++++++----- 4 files changed, 103 insertions(+), 30 deletions(-) diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 8ea2fe4793..b50999b2f2 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -23,8 +23,8 @@ pub struct PublishNamespace<'a> { /// whether they appear on the wire at all. pub cluster: Option, - /// The `AUTHORIZATION TOKEN` (0x03) this request presents. The parameter may repeat; the - /// first instance is the request's credential, and every instance has been decoded. + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. At most one is + /// accepted: decoding refuses a repeated or second token. pub authorization_token: Option, } @@ -114,8 +114,8 @@ pub struct PublishNamespaceUpdate { pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, - /// The `AUTHORIZATION TOKEN` (0x03) renewing the advertisement's credential, if any. The - /// parameter may repeat; the first instance is kept, and every instance has been decoded. + /// 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, } diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 3c3b16c361..8ddf6c439c 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -37,8 +37,8 @@ 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. The parameter may repeat; the - /// first instance is the request's credential, and every instance has been decoded. + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. At most one is + /// accepted: decoding refuses a repeated or second token. pub authorization_token: Option, } @@ -392,8 +392,8 @@ pub struct SubscribeUpdate { 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. The - /// parameter may repeat; the first instance is kept, and every instance has been decoded. + /// 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, } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index f9e0d31b08..dffcf11454 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -791,7 +791,7 @@ async fn connect_session( // Before the driver runs, so the first request carries the newest token. A session that // cannot carry one (moq-lite) fails the dial as Unsupported, which is terminal, rather // than drop it. - token.attach(session.auth())?; + token.attach(session.clone())?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index d333505737..38c3fdfa7d 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -23,8 +23,9 @@ impl Auth { /// `None`. Experimental: the peer's application must verify it, and success means only /// that the token was set. See [`moq_net::auth::Handle::set_request_token`]. /// - /// Replacing it renews the live session's token-authorized requests in band, except a - /// draft 14 or 15 advertisement, which has no update. + /// Replacing it renews the token-authorized requests in band on the live session and on + /// any session still draining after a GOAWAY, except a draft 14 or 15 advertisement, + /// which has no update. /// /// # Errors /// @@ -67,7 +68,7 @@ impl Auth { } } -/// The request token a connection presents and the session it presents it on, shared +/// The request token a connection presents and the sessions it presents it on, shared /// across its sessions and shown by length only so it stays out of logs. /// /// Seeding a new session ([`attach`](Self::attach)) and replacing the token @@ -82,18 +83,28 @@ struct Presenting { token: Option, /// The newest session, which a renewal reaches; `None` while nothing is live. session: Option>, + /// Sessions a newer one replaced that still serve their requests through a GOAWAY + /// drain, which a renewal reaches too until they end. + draining: Vec>, } -/// Where a request token is presented: a session's auth handle. +/// Where a request token is presented: a session. pub(crate) trait Present: Send { /// Present `token` on this session's requests from now on (or none), or refuse it when /// the session cannot carry one (moq-lite). fn present(&self, token: Option) -> Result<(), moq_net::Error>; + + /// Whether the session has ended, so nothing it serves needs the token any more. + fn ended(&self) -> bool; } -impl Present for auth::Handle { +impl Present for moq_net::Session { fn present(&self, token: Option) -> Result<(), moq_net::Error> { - self.set_request_token(token) + self.auth().set_request_token(token) + } + + fn ended(&self) -> bool { + self.closed().now_or_never().is_some() } } @@ -102,23 +113,31 @@ impl RequestToken { Self(std::sync::Arc::new(std::sync::Mutex::new(Presenting { token, session: None, + draining: Vec::new(), }))) } - /// Replace the token, on the newest session too, and seed every later session with it. + /// Replace the token, on the newest session and the draining ones too, and seed every + /// later session with it. /// /// Errors when the newest session cannot carry it, keeping the token set before: one the - /// live session refused would only fail the next reconnect for good. + /// live session refused would only fail the next reconnect for good. A draining session + /// that cannot carry it keeps what it had. fn set(&self, token: Option) -> Result<(), moq_net::Error> { let mut presenting = self.0.lock().unwrap(); if let Some(session) = &presenting.session { session.present(token.clone())?; } + presenting.draining.retain(|session| !session.ended()); + for session in &presenting.draining { + let _ = session.present(token.clone()); + } presenting.token = token; Ok(()) } - /// Seed `session` with the current token and make it the session later renewals reach. + /// Seed `session` with the current token and make it the newest session later renewals + /// reach, the one it replaces draining until it ends. /// /// Errors when the session cannot carry the token set, which leaves it unattached. #[cfg_attr( @@ -136,11 +155,16 @@ impl RequestToken { if presenting.token.is_some() { session.present(presenting.token.clone())?; } - presenting.session = Some(Box::new(session)); + let presenting = &mut *presenting; + presenting.draining.retain(|session| !session.ended()); + presenting + .draining + .extend(presenting.session.replace(Box::new(session))); Ok(()) } - /// Stop reaching the session once nothing is live; renewals then only update the seed. + /// Stop reaching the newest session once nothing is live; renewals then reach only what + /// still drains, and the seed. pub(crate) fn detach(&self) { // Also runs from `Shared`'s drop, so take a poisoned lock rather than panic while unwinding. self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).session = None; @@ -440,6 +464,10 @@ mod request_token_tests { self.presented.lock().unwrap().push(token); Ok(()) } + + fn ended(&self) -> bool { + false + } } /// Park the first present `write` makes on another thread, then race `renew` against it, @@ -488,6 +516,34 @@ mod request_token_tests { None => Ok(()), } } + + fn ended(&self) -> bool { + false + } + } + + /// A session double that records every token it is presented, ended once `ended` is set. + #[derive(Clone, Default)] + struct Records { + presented: std::sync::Arc>>>, + ended: std::sync::Arc, + } + + impl Records { + fn presented(&self) -> Vec> { + self.presented.lock().unwrap().clone() + } + } + + impl Present for Records { + fn present(&self, token: Option) -> Result<(), moq_net::Error> { + self.presented.lock().unwrap().push(token); + Ok(()) + } + + fn ended(&self) -> bool { + self.ended.load(std::sync::atomic::Ordering::Relaxed) + } } /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial @@ -511,21 +567,38 @@ mod request_token_tests { /// Clearing the token stops presenting it on the live session and seeds later ones with none. #[test] fn clearing_the_token_reaches_the_session() { - let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - struct Records(std::sync::Arc>>>); - impl Present for Records { - fn present(&self, token: Option) -> Result<(), moq_net::Error> { - self.0.lock().unwrap().push(token); - Ok(()) - } - } + let session = Records::default(); let token = RequestToken::new(Some(tok(b"token-a"))); - token.attach(Records(presented.clone())).unwrap(); + token.attach(session.clone()).unwrap(); token.set(None).unwrap(); - assert_eq!(*presented.lock().unwrap(), [Some(tok(b"token-a")), None]); + assert_eq!(session.presented(), [Some(tok(b"token-a")), None]); assert_eq!(token.get(), None, "later sessions carry none"); } + /// A session replaced by a GOAWAY migration still serves its requests while it drains, so + /// a renewal reaches it too until it ends, and the newest session either way. + #[test] + fn a_renewal_reaches_a_draining_session_until_it_ends() { + let (a, b) = (tok(b"token-a"), tok(b"token-b")); + let token = RequestToken::new(Some(a.clone())); + let old = Records::default(); + let new = Records::default(); + token.attach(old.clone()).unwrap(); + token.attach(new.clone()).unwrap(); + token.set(Some(b.clone())).unwrap(); + assert_eq!( + old.presented(), + [Some(a.clone()), Some(b.clone())], + "the draining session renews" + ); + assert_eq!(new.presented(), [Some(a.clone()), Some(b.clone())]); + + old.ended.store(true, std::sync::atomic::Ordering::Relaxed); + token.set(Some(a.clone())).unwrap(); + assert_eq!(old.presented().len(), 2, "an ended session is presented nothing more"); + assert_eq!(new.presented(), [Some(a.clone()), Some(b), Some(a)]); + } + /// A reconnect seeding its new session cannot replay the token it read over a renewal that /// lands meanwhile: the session ends on the renewal, as the reconnect seed does. #[test] From 66444afed0cba1833135b2a54c259a0f89b77f11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 08:40:45 -0700 Subject: [PATCH 42/53] fix(tokio): renew a draining session without owning it The presenter held strong Session clones of draining predecessors, so an abort or a dropped connection during a handover left the predecessor alive with no deadline. It now keeps only the auth handle, registered and released by the loop's Draining, which already bounds the predecessor's lifetime. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/client.rs | 2 +- rs/moq-tokio/src/connection.rs | 13 ++-- rs/moq-tokio/src/connection/auth.rs | 97 +++++++++++++++-------------- 3 files changed, 59 insertions(+), 53 deletions(-) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index dffcf11454..f9e0d31b08 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -791,7 +791,7 @@ async fn connect_session( // Before the driver runs, so the first request carries the newest token. A session that // cannot carry one (moq-lite) fails the dial as Unsupported, which is terminal, rather // than drop it. - token.attach(session.clone())?; + token.attach(session.auth())?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 64545ccc1a..0a0a394866 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -906,7 +906,7 @@ impl Connection { if let Some(mut old) = draining.take() { old.retire(); } - draining = Some(Draining::new(session, goaway.handover(msg.timeout()), &shared.state)); + draining = Some(Draining::new(session, goaway.handover(msg.timeout()), shared)); // After the predecessor is published, so a close woken by this // status finds it and honors its handover deadline. shared.migrating(); @@ -1317,14 +1317,17 @@ struct Draining { deadline: std::pin::Pin>, /// Mirrors the session into [`State::predecessor`] for as long as this lives. state: kio::Producer, + /// Renews the session's token-authorized requests for as long as this lives. + request_token: RequestToken, } impl Draining { - fn new(session: moq_net::Session, handover: Duration, state: &kio::Producer) -> Self { + fn new(session: moq_net::Session, handover: Duration, shared: &Shared) -> Self { let deadline = Box::pin(tokio::time::sleep(handover)); - if let Ok(mut state) = state.write() { + if let Ok(mut state) = shared.state.write() { state.predecessor = Some((session.clone(), deadline.deadline())); } + shared.request_token.drain(); let closed = { let session = session.clone(); Box::pin(async move { @@ -1336,7 +1339,8 @@ impl Draining { session, closed, deadline, - state: state.clone(), + state: shared.state.clone(), + request_token: shared.request_token.clone(), } } @@ -1365,6 +1369,7 @@ impl Drop for Draining { if let Ok(mut state) = self.state.write() { state.predecessor = None; } + self.request_token.drained(); } } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 38c3fdfa7d..41b1d3bc7c 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -83,28 +83,22 @@ struct Presenting { token: Option, /// The newest session, which a renewal reaches; `None` while nothing is live. session: Option>, - /// Sessions a newer one replaced that still serve their requests through a GOAWAY - /// drain, which a renewal reaches too until they end. - draining: Vec>, + /// The session a GOAWAY migration replaced, still serving its requests until the + /// connection retires it, which a renewal reaches too. + draining: Option>, } -/// Where a request token is presented: a session. +/// Where a request token is presented: a session's auth handle, which never keeps the +/// session itself alive. pub(crate) trait Present: Send { /// Present `token` on this session's requests from now on (or none), or refuse it when /// the session cannot carry one (moq-lite). fn present(&self, token: Option) -> Result<(), moq_net::Error>; - - /// Whether the session has ended, so nothing it serves needs the token any more. - fn ended(&self) -> bool; } -impl Present for moq_net::Session { +impl Present for auth::Handle { fn present(&self, token: Option) -> Result<(), moq_net::Error> { - self.auth().set_request_token(token) - } - - fn ended(&self) -> bool { - self.closed().now_or_never().is_some() + self.set_request_token(token) } } @@ -113,12 +107,12 @@ impl RequestToken { Self(std::sync::Arc::new(std::sync::Mutex::new(Presenting { token, session: None, - draining: Vec::new(), + draining: None, }))) } - /// Replace the token, on the newest session and the draining ones too, and seed every - /// later session with it. + /// Replace the token, on the newest session and a draining one too, and seed every later + /// session with it. /// /// Errors when the newest session cannot carry it, keeping the token set before: one the /// live session refused would only fail the next reconnect for good. A draining session @@ -128,8 +122,7 @@ impl RequestToken { if let Some(session) = &presenting.session { session.present(token.clone())?; } - presenting.draining.retain(|session| !session.ended()); - for session in &presenting.draining { + if let Some(session) = &presenting.draining { let _ = session.present(token.clone()); } presenting.token = token; @@ -137,7 +130,7 @@ impl RequestToken { } /// Seed `session` with the current token and make it the newest session later renewals - /// reach, the one it replaces draining until it ends. + /// reach. /// /// Errors when the session cannot carry the token set, which leaves it unattached. #[cfg_attr( @@ -155,20 +148,32 @@ impl RequestToken { if presenting.token.is_some() { session.present(presenting.token.clone())?; } - let presenting = &mut *presenting; - presenting.draining.retain(|session| !session.ended()); - presenting - .draining - .extend(presenting.session.replace(Box::new(session))); + presenting.session = Some(Box::new(session)); Ok(()) } - /// Stop reaching the newest session once nothing is live; renewals then reach only what - /// still drains, and the seed. + /// Stop reaching the newest session once nothing is live; renewals then reach only one + /// still draining, and the seed. pub(crate) fn detach(&self) { // Also runs from `Shared`'s drop, so take a poisoned lock rather than panic while unwinding. self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).session = None; } + + /// Keep renewing the newest session while a GOAWAY migration drains it, until + /// [`drained`](Self::drained). + pub(crate) fn drain(&self) { + let mut presenting = self.0.lock().unwrap(); + presenting.draining = presenting.session.take(); + } + + /// Stop reaching the session [`drain`](Self::drain) kept, once the connection retires it. + pub(crate) fn drained(&self) { + // Runs from `Draining`'s drop, so take a poisoned lock rather than panic while unwinding. + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .draining = None; + } } impl std::fmt::Debug for RequestToken { @@ -464,10 +469,6 @@ mod request_token_tests { self.presented.lock().unwrap().push(token); Ok(()) } - - fn ended(&self) -> bool { - false - } } /// Park the first present `write` makes on another thread, then race `renew` against it, @@ -516,17 +517,12 @@ mod request_token_tests { None => Ok(()), } } - - fn ended(&self) -> bool { - false - } } - /// A session double that records every token it is presented, ended once `ended` is set. + /// A session double that records every token it is presented. #[derive(Clone, Default)] struct Records { presented: std::sync::Arc>>>, - ended: std::sync::Arc, } impl Records { @@ -540,10 +536,6 @@ mod request_token_tests { self.presented.lock().unwrap().push(token); Ok(()) } - - fn ended(&self) -> bool { - self.ended.load(std::sync::atomic::Ordering::Relaxed) - } } /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial @@ -576,27 +568,36 @@ mod request_token_tests { } /// A session replaced by a GOAWAY migration still serves its requests while it drains, so - /// a renewal reaches it too until it ends, and the newest session either way. + /// a renewal reaches it too until the connection retires it, and the newest session either + /// way. #[test] - fn a_renewal_reaches_a_draining_session_until_it_ends() { + fn a_renewal_reaches_a_draining_session_until_it_retires() { let (a, b) = (tok(b"token-a"), tok(b"token-b")); let token = RequestToken::new(Some(a.clone())); let old = Records::default(); let new = Records::default(); token.attach(old.clone()).unwrap(); - token.attach(new.clone()).unwrap(); + token.drain(); token.set(Some(b.clone())).unwrap(); assert_eq!( old.presented(), [Some(a.clone()), Some(b.clone())], - "the draining session renews" + "renewed between sessions" ); - assert_eq!(new.presented(), [Some(a.clone()), Some(b.clone())]); - old.ended.store(true, std::sync::atomic::Ordering::Relaxed); + token.attach(new.clone()).unwrap(); token.set(Some(a.clone())).unwrap(); - assert_eq!(old.presented().len(), 2, "an ended session is presented nothing more"); - assert_eq!(new.presented(), [Some(a.clone()), Some(b), Some(a)]); + assert_eq!( + old.presented(), + [Some(a.clone()), Some(b.clone()), Some(a.clone())], + "renewed while draining" + ); + assert_eq!(new.presented(), [Some(b.clone()), Some(a.clone())]); + + token.drained(); + token.set(Some(b.clone())).unwrap(); + assert_eq!(old.presented().len(), 3, "a retired session is presented nothing more"); + assert_eq!(new.presented(), [Some(b.clone()), Some(a), Some(b)]); } /// A reconnect seeding its new session cannot replay the token it read over a renewal that From 8f63edf8f7f68fcbe9e5010d77720154bfe7d3bb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:04:49 -0700 Subject: [PATCH 43/53] fix(net): a refused renewal ends the subscription with its refusal The REQUEST_ERROR refusing a token renewal was only logged, so the track ended on the following PUBLISH_DONE as an opaque remote code (UPDATE_FAILED, or INTERNAL_ERROR on draft 14). It now ends with the refusal's own error, as an initial refusal and an expiry do. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 77 +++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index a5ab5267d1..189b32ae48 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3227,19 +3227,25 @@ where /// answers a refused one with SUBSCRIBE_ERROR). Each answer bumps `answers` so the send /// loop can release the renewal it coalesced behind the one in flight; the read continues /// regardless, since the subscription ends on its PUBLISH_DONE, which the publisher sends - /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. + /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. That + /// PUBLISH_DONE fails with the refusal's code, as an initial refusal does, rather than its + /// own generic status. async fn read_publish_done( reader: &mut Reader, version: Version, answers: &kio::Shared, ) -> Result { + let mut refused = None; loop { match reader.varint_maybe().await? { Some(ietf::PublishDone::ID) => { let msg: ietf::PublishDone = reader.decode().await?; tracing::debug!(message = ?msg, "received publish done"); - msg.end(version)?; - return Ok(msg.stream_count); + return match (msg.end(version), refused) { + (Ok(()), _) => Ok(msg.stream_count), + (Err(_), Some(refusal)) => Err(refusal), + (Err(err), None) => Err(err), + }; } Some(ietf::RequestOk::ID) => { let msg: ietf::RequestOk = reader.decode().await?; @@ -3251,16 +3257,19 @@ where // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the // renewal; the subscription then ends on the PUBLISH_DONE that follows, not // on this answer. - match version { + let code = match version { Version::Draft14 => { let msg: ietf::SubscribeError = reader.decode().await?; tracing::warn!(message = ?msg, "request token renewal refused"); + msg.error_code } _ => { let msg: ietf::RequestError = reader.decode().await?; tracing::warn!(message = ?msg, "request token renewal refused"); + msg.error_code } - } + }; + refused = Some(request::from_code(code, request::Kind::Subscribe, version)); *answers.lock() += 1; } Some(_) => return Err(Error::UnexpectedMessage), @@ -5289,6 +5298,64 @@ mod tests { use super::*; + /// A refused renewal ends the subscription with the refusal's own error, as an initial + /// refusal does, not the generic status of the PUBLISH_DONE that follows it. + #[moq_net_sim::test] + async fn a_refused_renewal_ends_the_subscription_with_its_refusal() { + use crate::lite::test_transport::ScriptedSession; + use crate::transport::poll::Session as _; + for version in [Version::Draft14, Version::Draft16, Version::Draft19, Version::Draft22] { + 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 code = request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version); + match version { + Version::Draft14 => { + writer.varint(ietf::SubscribeError::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeError { + request_id: RequestId(2), + error_code: code, + reason_phrase: "no".into(), + }) + .await + .unwrap(); + } + _ => { + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id: (version == Version::Draft16).then_some(RequestId(2)), + error_code: code, + reason_phrase: "no".into(), + retry_interval: 0, + }) + .await + .unwrap(); + } + } + writer.varint(ietf::PublishDone::ID).await.unwrap(); + writer + .encode(&ietf::PublishDone { + request_id: (version != Version::Draft19 && version != Version::Draft22).then_some(RequestId(0)), + status_code: 0x8, + stream_count: 0, + reason_phrase: "update failed".into(), + }) + .await + .unwrap(); + let script = log.writes.lock().unwrap().clone(); + + let mut session = ScriptedSession::eof(script); + let (_, recv) = session.open_bi().await.unwrap(); + let mut reader = Reader::new(recv, version); + let answers = kio::Shared::new(0); + let result = Subscriber::::read_publish_done(&mut reader, version, &answers).await; + assert!(matches!(result, Err(Error::Unauthorized)), "{version}: {result:?}"); + assert_eq!(*answers.lock(), 1, "{version}"); + } + } + async fn check_publish_fin(responses: Vec, clean: bool) { use crate::lite::test_transport::ScriptedSession; use crate::transport::poll::Session as _; From b505f3cd22bd6dc426c8917fb7a0dec2e7137202 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:27:22 -0700 Subject: [PATCH 44/53] fix(net): carry a renewal refusal to the subscriber, and send nothing after it Draft 14 answers no update and registers no UPDATE_FAILED, so its PUBLISH_DONE now carries the refusal itself (UNAUTHORIZED for a refused token) instead of INTERNAL_ERROR. A REQUEST_ERROR no longer releases a renewal queued behind the refused one: the subscription is ending, so nothing more is presented on it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 41 ++++++++++++++++++++----------- rs/moq-net/src/ietf/subscriber.rs | 8 +++--- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1d3926497b..376dee445b 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -342,7 +342,7 @@ enum Served { 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. + /// PUBLISH_DONE says UPDATE_FAILED, or on draft 14 the refusal itself. UpdateFailed(Error), } @@ -356,8 +356,11 @@ enum Subscribed { impl Served { /// The PUBLISH_DONE status and reason this end reports. - fn status(&self) -> (ietf::PublishDoneStatus, &'static str) { + 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") @@ -1101,7 +1104,7 @@ where } }, }; - let (status, reason) = served.status(); + let (status, reason) = served.status(self.version); let res = match served { Served::Track(res) => res, Served::UpdateFailed(err) => Err(err), @@ -5587,11 +5590,19 @@ mod serve_tests { .await .expect("the track's error ends the subscription"); assert!(matches!(served, Served::Track(Err(Error::Unsupported))), "{served:?}"); - assert_eq!(served.status().0, ietf::PublishDoneStatus::InternalError); assert_eq!( - Served::UpdateFailed(Error::Unsupported).status().0, + 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 @@ -9337,9 +9348,9 @@ mod serve_tests { } } - /// A refused renewal ends only its subscription at once (PUBLISH_DONE UPDATE_FAILED), 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. + /// 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] { @@ -9463,16 +9474,18 @@ mod serve_tests { 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"); - assert_eq!( - status, - ietf::PublishDoneStatus::UpdateFailed.code(version), - "{version:?}: UPDATE_FAILED" - ); + 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 UPDATE_FAILED, written by the caller), with no REQUEST_ERROR. + // 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. _ => { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 189b32ae48..53f2ce1cba 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3224,8 +3224,9 @@ where /// /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream /// (REQUEST_OK / REQUEST_ERROR on draft-15+; draft-14 is silent on an accepted renewal and - /// answers a refused one with SUBSCRIBE_ERROR). Each answer bumps `answers` so the send - /// loop can release the renewal it coalesced behind the one in flight; the read continues + /// may answer a refused one with SUBSCRIBE_ERROR). Each acceptance bumps `answers` so the + /// send loop can release the renewal it coalesced behind the one in flight. A refusal does + /// not: the subscription is ending, so nothing more is presented on it. The read continues /// regardless, since the subscription ends on its PUBLISH_DONE, which the publisher sends /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. That /// PUBLISH_DONE fails with the refusal's code, as an initial refusal does, rather than its @@ -3270,7 +3271,6 @@ where } }; refused = Some(request::from_code(code, request::Kind::Subscribe, version)); - *answers.lock() += 1; } Some(_) => return Err(Error::UnexpectedMessage), None => return Err(Error::ProtocolViolation), @@ -5352,7 +5352,7 @@ mod tests { let answers = kio::Shared::new(0); let result = Subscriber::::read_publish_done(&mut reader, version, &answers).await; assert!(matches!(result, Err(Error::Unauthorized)), "{version}: {result:?}"); - assert_eq!(*answers.lock(), 1, "{version}"); + assert_eq!(*answers.lock(), 0, "{version}: a refusal releases no queued renewal"); } } From 59a55c792715a1874d87ed3fc6ea7ad45bae1854 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:37:00 -0700 Subject: [PATCH 45/53] refactor(auth): a request token is one more auth on requests() Rework the request-token hook into step 1 of the unified auth model: incoming only. A moq-transport AUTHORIZATION TOKEN on SUBSCRIBE or PUBLISH_NAMESPACE reaches the application on auth::Handle::requests() like any AUTH token. Its grant admits only that request, never joins the session scope, and ends with it; renewal names a new auth that replaces the old. The session scope is now the connection credential's grant (authorize) plus every grant issued to an AUTH token, which moq-net tracks itself, so the relay authorizes only its connection credentials. Drop the outgoing request token: auth::Handle::set_request_token, RequestToken, the scoped acceptor variants, moq-tokio's Config::with_request_token and Auth::set_request_token, and their renewal machinery. Outgoing tokens return later through add(). Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 8 +- doc/concept/standard.md | 2 +- quest/m1/auth/README.md | 37 +- quest/m1/auth/request-gate.md | 28 +- quest/m3/README.md | 2 +- quest/m3/auth-request-lease.md | 48 +- rs/moq-net/src/auth.rs | 942 +++++++---------- rs/moq-net/src/coding/writer.rs | 14 +- rs/moq-net/src/ietf/adapter.rs | 13 +- rs/moq-net/src/ietf/control.rs | 50 - rs/moq-net/src/ietf/publisher.rs | 1373 +------------------------ rs/moq-net/src/ietf/session.rs | 269 +---- rs/moq-net/src/ietf/subscriber.rs | 1363 +++--------------------- rs/moq-net/src/lib.rs | 2 - rs/moq-net/src/lite/test_transport.rs | 15 +- rs/moq-net/src/server.rs | 41 +- rs/moq-net/src/session.rs | 3 - rs/moq-net/tests/auth.rs | 716 +------------ rs/moq-relay/src/auth/refresh.rs | 4 +- rs/moq-tokio/src/client.rs | 48 +- rs/moq-tokio/src/connect.rs | 42 - rs/moq-tokio/src/connection.rs | 223 +--- rs/moq-tokio/src/connection/auth.rs | 382 ------- rs/moq-tokio/src/error.rs | 10 - 24 files changed, 628 insertions(+), 5007 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 83e4923665..5fdb194ca1 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -143,10 +143,10 @@ 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. The relay does -not verify a moq-transport `AUTHORIZATION TOKEN` on a `SUBSCRIBE` or -`PUBLISH_NAMESPACE` either, so it refuses that request as unsupported, even one -the connection's grant covers; send the credential at connect time instead. 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 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 5bf8677079..6c825ef743 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** 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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified and authorizes that one request, even where our own authorization of the peer already covers it, and is refused with `NOT_SUPPORTED` when the application does not verify request tokens, as moq-relay does not. A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. +- **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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where our own authorization of the peer already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when the application verifies no tokens, or when its Token Type is anything but 0 (out of band). A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 15336b20b3..732f79e33c 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -23,17 +23,25 @@ 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; grants received - from the peer may not. The shared acceptor queue distinguishes session and - request events with separate acceptance handles. This is an intentional - Rust API change, not an optional scope field on the existing acceptor. - 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. - **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 +107,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, while the request-token hook intentionally -changes the Rust acceptor API as described above. 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 @@ -122,8 +129,8 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. ## 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 - [#5107](https://github.com/moq-dev/moq/pull/5107) - landed request-token decoding, including session-fatal REQUEST_UPDATE decode failures - [#5106](https://github.com/moq-dev/moq/pull/5106) - landed setup extension diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md index f97792f193..b520465184 100644 --- a/quest/m1/auth/request-gate.md +++ b/quest/m1/auth/request-gate.md @@ -22,20 +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.** Dispatch - uses independently verified local session permission first, then the - admitted request grant; 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 @@ -44,9 +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. +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/m3/README.md b/quest/m3/README.md index 0ccd319b7a..875a2b579b 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 - [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 - [The moq.pro mesh runs lite-07](/quest/m3/lite07-mesh.md) - the deployment that makes the exemption dead code - [Drop the hidden cluster exemption](/quest/m3/hidden-exemption.md) - relays stop forcing hidden broadcasts on cluster peers once every peer opts in on the wire diff --git a/quest/m3/auth-request-lease.md b/quest/m3/auth-request-lease.md index 0407c53bdb..ad7632b299 100644 --- a/quest/m3/auth-request-lease.md +++ b/quest/m3/auth-request-lease.md @@ -1,40 +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`, though it consumes -session-token events. Requests independently covered by locally verified session permission -do not require request-token verification. +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 acceptor built on moq-net answers request tokens through -the explicit request variant of `requests()` itself and does not need this; -it is the relay's half of the request-token hook from -[#5148](https://github.com/moq-dev/moq/pull/5148). +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 order (session grant, then the - request's token, then `UNAUTHORIZED`) and that a request token covers only +- 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 never counts a request token as a - session grant and sees its lease end with the 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. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 4fbc7eb24a..fbf5e356c1 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 @@ -22,8 +28,7 @@ //! union of subtrees rather than widen it. use std::{ - collections::{BTreeMap, HashMap, VecDeque}, - sync::{Arc, Mutex}, + collections::{BTreeMap, VecDeque}, task::Poll, }; @@ -119,8 +124,6 @@ fn earliest(a: Option, b: Option) -> Option { pub(crate) struct State { /// Whether the negotiated version carries AUTH at all. supported: bool, - /// The session speaks moq-lite, whose requests carry no token. - lite: bool, /// Set once the session ends; every waiter resolves with it. closed: Option, #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -133,16 +136,22 @@ 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, - /// The application's queue of the peer's request tokens, once it took [`Handle::scoped`]. - verifier: Option>, } /// One presented token. @@ -171,7 +180,7 @@ enum Acceptor { Undecided, /// The app answers every token. #[cfg_attr(not(feature = "async"), allow(dead_code))] - App(Pending), + App(Pending), /// The session answers from its own origin handles. #[cfg_attr(not(feature = "async"), allow(dead_code))] Default, @@ -193,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), ), } } @@ -276,114 +300,12 @@ impl Permit { } } -/// The `AUTHORIZATION TOKEN` this side presents on its own SUBSCRIBE and PUBLISH_NAMESPACE -/// requests (MoQ request-token), shared between a session's [`Handle`] and its driver. -/// -/// The session reads the current value when it first sends a request, and re-presents a -/// changed one on each live request as a REQUEST_UPDATE. The default presents no token, -/// which is byte-identical to a session that never sets one. -#[derive(Clone, Default)] -#[cfg_attr(not(feature = "async"), allow(dead_code))] -pub(crate) struct RequestToken { - token: kio::Shared>, - /// Broadcasts whose live advertisement presented the token, counted per advertisement. - /// They keep standing on it after it is cleared, so grant enforcement leaves them be. - backed: Arc>>, -} - -#[cfg_attr(not(feature = "async"), allow(dead_code))] -impl RequestToken { - /// A credential presenting `token` (or none) until replaced. - #[cfg(test)] - pub(crate) fn new(token: Option) -> Self { - Self { - token: kio::Shared::new(token), - backed: Default::default(), - } - } - - /// The token an advertisement of `path` presents now, with a guard marking `path` as backed - /// by it until dropped. Read under the token's lock, so a concurrent clear sees either no - /// token sent or the path already backed. - pub(crate) fn present(&self, path: &crate::PathOwned) -> (Option, Option) { - let token = self.token.read(); - let backing = token.is_some().then(|| self.back(path.clone())); - ((*token).clone(), backing) - } - - /// Mark `path` as backed by the token, such as after an accepted renewal. - pub(crate) fn back(&self, path: crate::PathOwned) -> Backing { - *self - .backed - .lock() - .unwrap_or_else(|e| e.into_inner()) - .entry(path.clone()) - .or_default() += 1; - Backing { - backed: self.backed.clone(), - path, - } - } - - /// Whether a live advertisement of `path` presented the token. - pub(crate) fn backs(&self, path: &crate::PathOwned) -> bool { - self.backed.lock().unwrap_or_else(|e| e.into_inner()).contains_key(path) - } - - /// Replace the token presented on this session's requests. A live request re-presents - /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. - pub(crate) fn set(&self, token: Option) { - *self.token.lock() = token; - } - - /// The token to present right now, read when a request is first sent. - pub(crate) fn peek(&self) -> Option { - self.token.read().clone() - } - - /// Ready with the current token once it differs from `last`, registering `waiter` - /// otherwise. The send loops park here to re-present a replaced token on their live - /// requests; it reads without advancing `last`, so a poll that loses its turn to - /// another arm is re-offered the change rather than dropping it. - pub(crate) fn poll_changed( - &self, - last: &Option, - waiter: &kio::Waiter, - ) -> Poll> { - let guard = ready_or!(self.token.poll(waiter, |cur| match **cur == *last { - true => Poll::Pending, - false => Poll::Ready(()), - })); - Poll::Ready((*guard).clone()) - } -} - -/// One advertisement standing on the request token it presented; dropping it ends that. -#[cfg_attr(not(feature = "async"), allow(dead_code))] -pub(crate) struct Backing { - backed: Arc>>, - path: crate::PathOwned, -} - -impl Drop for Backing { - fn drop(&mut self) { - let mut backed = self.backed.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(count) = backed.get_mut(&self.path) { - *count -= 1; - if *count == 0 { - backed.remove(&self.path); - } - } - } -} - /// The session's auth handle, returned by `Session::auth`. /// /// Cheap to clone; every clone shares the session's tokens. #[derive(Clone)] pub struct Handle { state: kio::Shared, - request_token: RequestToken, } impl Handle { @@ -395,53 +317,9 @@ impl Handle { supported, ..Default::default() }), - request_token: RequestToken::default(), } } - /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own SUBSCRIBE and - /// PUBLISH_NAMESPACE requests (MoQ request-token), or stop presenting one with `None`. - /// Unlike `add`, which presents a credential for the whole session, the - /// peer verifies this one per request. Experimental. - /// - /// Set it before running the session's driver to present it on the first request. - /// Replacing it later re-presents the new token on every live request that carried one, - /// as a REQUEST_UPDATE renewing it in place; a request sent without one stays without. - /// Setting the same value again sends nothing. A subscription whose renewal cannot be - /// sent ends. Only drafts 14 and 15 cannot renew an advertisement, which has no update - /// there, so it keeps the token it was offered with. `None` sends nothing either: live - /// requests keep the token they last presented, and later ones carry none. - /// Independent of the MoQ Auth extension. - /// - /// While a token is set, this side stops closing the session over a broadcast its own - /// grant does not cover, since each announce carries the token for the peer to verify. - /// Clearing it resumes that check for broadcasts announced afterwards. - /// - /// # Errors - /// - /// [`Error::Unsupported`] for a token on a moq-lite session, whose requests cannot carry - /// one, rather than drop it. - pub fn set_request_token(&self, token: Option) -> Result<()> { - if token.is_some() && self.state.read().lite { - return Err(Error::Unsupported); - } - self.request_token.set(token); - Ok(()) - } - - /// The session speaks moq-lite, so [`set_request_token`](Self::set_request_token) and - /// [`scoped`](Self::scoped) refuse. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn lite(&self) { - self.state.lock().lite = true; - } - - /// The request-token cell the session's publisher and subscriber present from. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn request_token(&self) -> RequestToken { - self.request_token.clone() - } - /// Whether this side speaks AUTH: false on a version without it, on a side that does /// not offer it, and once the peer turns out not to. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -502,14 +380,15 @@ impl Handle { }) } - /// Answer every session token the peer presents, instead of the default acceptor. - /// A token on one of the peer's requests goes to [`scoped`](Self::scoped) instead. + /// 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, @@ -519,90 +398,48 @@ impl Handle { /// /// [`Error::Duplicate`] if the requests were already taken, or the driver /// already started answering them itself. - pub fn requests(&self) -> Result> { + pub fn requests(&self) -> Result { let mut state = self.state.lock(); if !matches!(state.acceptor, Acceptor::Undecided) { 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 { - queue.close(); - } - state.acceptor = Acceptor::App(queue.clone()); - Ok(Requests { queue }) - } - - /// Verify the `AUTHORIZATION TOKEN` on each of the peer's SUBSCRIBE and PUBLISH_NAMESPACE - /// requests, even one [`authorize`](Self::authorize) covers. Experimental. - /// - /// Each token decides its request alone, with a grant that never joins the session's. - /// Without this, a request carrying one is refused as unsupported, and a session never - /// authorized admits every request without reading its token. A request or update - /// carrying more than one token is refused as unsupported. Take it before running the - /// session's driver, so no request is admitted before it. Independent of the MoQ Auth - /// extension, since the token rides the request itself. - /// - /// At most 128 unanswered tokens queue, not counting withdrawn requests; more are - /// refused with [`SessionError::TooManyRequests`]. - /// - /// # Errors - /// - /// [`Error::Duplicate`] if already taken, or [`Error::Unsupported`] on moq-lite, whose - /// requests carry no token. - pub fn scoped(&self) -> Result> { - let mut state = self.state.lock(); - if state.lite { - return Err(Error::Unsupported); - } - if state.verifier.is_some() { - return Err(Error::Duplicate); - } - let queue = Pending::default(); if state.closed.is_some() { queue.close(); } - state.verifier = Some(queue.clone()); + state.acceptor = Acceptor::App(queue.clone()); Ok(Requests { queue }) } - /// Ask the application to verify a token that rode on one request. + /// Ask the acceptor to verify a token that rode on one of the peer's requests. /// - /// The token reaches it through [`scoped`](Self::scoped). The grant it answers covers only - /// this request, never joins the session union, and ends when the returned - /// [`RequestVerdict`] is dropped. Without a verifier the token cannot be verified, so the - /// verdict is [`Error::Unsupported`]. `hold` is the request's slot, kept while the - /// application verifies the token. + /// 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, or for a token + /// type other than [`Token::OUT_OF_BAND`](crate::setup::Token::OUT_OF_BAND), which an AUTH + /// token cannot name, the verdict is [`Error::Unsupported`]. `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, - path: crate::PathOwned, - kind: RequestKind, - hold: Option, - ) -> RequestVerdict { - let Some(queue) = self.state.read().verifier.clone() else { + 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 { + return RequestVerdict { issue: None }; + } let issue = kio::Shared::new(Issue { hold, ..Default::default() }); // A closed or full queue refuses the token. - queue.push(Scoped { - token, - path, - kind, - issue: Some(issue.clone()), - }); + 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))] - pub(crate) fn acceptor(&self) -> Option> { + pub(crate) fn acceptor(&self) -> Option { let mut state = self.state.lock(); match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -711,32 +548,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 request that carries its own token is decided by that token, inside it or not, - /// through [`scoped`](Self::scoped), or refused as unsupported when nothing verifies them. + /// 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 grant ends what falls outside at once, with [`Error::Unauthorized`] + /// 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))] @@ -779,17 +632,17 @@ impl Handle { self.state.read().allows(direction, path) } - /// Whether a request token decides the request carrying it, rather than this side's own - /// authorization of the peer: the application verifies every token, or the request is - /// refused as unsupported without a verifier, even where the limit already covers it. A - /// token admitted unread would leave the peer believing it backs a request this side holds - /// to its limit. A side that neither limits the peer nor verifies tokens has nothing to - /// check one against, and admits as it would without it. + /// 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 (_, limit) = state.parts(direction); - limit.is_some() || state.verifier.is_some() + 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 @@ -801,17 +654,18 @@ impl Handle { granted.is_none_or(|granted| granted.matches(path)) } - /// Whether the limit alone allows `direction` at `path`: for a route the peer + /// 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(); @@ -823,10 +677,6 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - // Request tokens ride the requests themselves, so their queue stays open. - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } } /// End the session: fail every pending token, end every watch, and close the @@ -845,10 +695,13 @@ impl Handle { slot.ended.get_or_insert(err.clone()); } state.recompute(); - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } - if let Some(queue) = &state.verifier { + 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(); } } @@ -1004,134 +857,72 @@ impl Watch { } } -/// The tokens the peer presents, for an application that answers them itself: session -/// tokens ([`Request`]) from [`Handle::requests`], and tokens on single requests -/// ([`Scoped`]) from [`Handle::scoped`]. +/// The tokens the peer presents, for an application that answers them itself. /// -/// Dropping it refuses every later token. -pub struct Requests { - queue: Pending, +/// Returned by [`Handle::requests`]. Dropping it refuses every later token. +pub struct Requests { + queue: Pending, } -impl Requests { - /// Poll for the next session token, or `None` once the session ends. +impl Requests { + /// Poll for the next token, or `None` once the session ends. pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { self.queue.poll_next(waiter) } - /// Wait for the next session token the peer presents, or `None` once the session ends. + /// Wait for the next token the peer presents, or `None` once the session ends. #[cfg(feature = "async")] pub async fn next(&mut self) -> Option { kio::wait(|waiter| self.poll_next(waiter)).await } } -impl Requests { - /// Poll for the next request token, or `None` once the session ends. - pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { - self.queue.poll_next(waiter) - } - - /// Wait for the next request token the peer presents, or `None` once the session ends. - #[cfg(feature = "async")] - pub async fn next(&mut self) -> Option { - kio::wait(|waiter| self.poll_next(waiter)).await - } -} - -impl Drop for Requests { +impl Drop for Requests { fn drop(&mut self) { self.queue.close(); } } -/// A token waiting in a [`Pending`] queue for the application's answer. -pub(crate) trait Queued { - /// What the session and the application share about it. - fn issue(&self) -> &kio::Shared; - /// Refuse it without asking the application. - fn refuse(self, code: SessionError, reason: &str); -} - -impl Queued for Request { - fn issue(&self) -> &kio::Shared { - self.issue.as_ref().expect("unanswered request") - } - - fn refuse(self, code: SessionError, reason: &str) { - self.reject(code, reason) - } -} - -impl Queued for Scoped { - fn issue(&self) -> &kio::Shared { - self.issue.as_ref().expect("unanswered request") - } - - fn refuse(self, code: SessionError, reason: &str) { - self.reject(code, reason) - } -} - /// Pending application decisions, bounded independently of transport stream credit. -pub(crate) struct Pending { - state: kio::Shared>, -} - -impl Clone for Pending { - fn clone(&self) -> Self { - Self { - state: self.state.clone(), - } - } -} - -impl Default for Pending { - fn default() -> Self { - Self { - state: kio::Shared::new(PendingState { - requests: VecDeque::new(), - closed: false, - }), - } - } +#[derive(Clone, Default)] +pub(crate) struct Pending { + state: kio::Shared, } -struct PendingState { - requests: VecDeque, +#[derive(Default)] +struct PendingState { + requests: VecDeque, closed: bool, } -impl Pending { +impl Pending { fn close(&self) { let mut state = self.state.lock(); state.closed = true; state.requests.clear(); } -} -impl Pending { /// Queue the token for the application, or refuse it when closed or full. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn push(&self, request: T) { + pub(crate) fn push(&self, request: Request) { let mut state = self.state.lock(); // Dropping the request refuses it as unanswered. if state.closed { return; } - // A withdrawn stream must not occupy a slot while the verifier is busy. + // A withdrawn token must not occupy a slot while the acceptor is busy. if state.requests.len() >= 128 { state.requests.retain(|request| request.issue().read().peer.is_none()); } if state.requests.len() >= 128 { drop(state); - request.refuse(SessionError::TooManyRequests, "too many pending tokens"); + request.reject(SessionError::TooManyRequests, "too many pending tokens"); return; } state.requests.push_back(request); } - fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { + fn poll_next(&self, waiter: &kio::Waiter) -> Poll> { loop { let mut state = std::task::ready!(self.state.poll(waiter, |state| { if state.closed || !state.requests.is_empty() { @@ -1176,8 +967,33 @@ pub(crate) struct Issue { 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 request token off the queue. + /// 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 @@ -1199,16 +1015,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); + } } } @@ -1219,7 +1048,11 @@ impl Drop for Serving { } } -/// A token for the whole session, waiting for an answer. +/// 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 { @@ -1236,27 +1069,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 } - /// 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 } } @@ -1264,6 +1104,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; } } @@ -1271,6 +1112,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; } } } @@ -1278,15 +1120,15 @@ 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)] -#[non_exhaustive] -pub enum RequestKind { +#[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. @@ -1306,71 +1148,6 @@ impl RequestKind { } } -/// A token on one SUBSCRIBE or PUBLISH_NAMESPACE, waiting for an answer. Experimental. -/// -/// The session asks for every request carrying a token, even one its own authorization of -/// the peer already covers, and the application verifies the token: carrying one proves nothing. The grant must cover -/// the request, never joins the session's grant, and ends with the request. -/// -/// Dropping it unanswered refuses the request with [`SessionError::Unauthorized`]. -pub struct Scoped { - token: crate::setup::Token, - path: crate::PathOwned, - kind: RequestKind, - issue: Option>, -} - -impl Scoped { - /// The token the request carried. - pub fn token(&self) -> &crate::setup::Token { - &self.token - } - - /// The request's path: the track's namespace, or the namespace announced. - pub fn path(&self) -> &crate::Path<'static> { - &self.path - } - - /// Which request carried the token. - pub fn kind(&self) -> RequestKind { - self.kind - } - - /// Wait for the request to end before an answer, or the session to close. - #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - closed(self.issue.as_ref().expect("unanswered request")).await - } - - /// Admit the request on `grant`, which must cover it. The session enforces the grant on - /// that request alone until the returned [`Enforced`] is revoked or dropped, `expires` - /// passes, or the request ends. - pub fn accept(mut self, grant: Grant) -> Enforced { - let issue = self.issue.take().expect("answered once"); - let mut locked = issue.lock(); - locked.outbox.push_back(Reply::Grant(grant)); - locked.hold = None; - drop(locked); - Enforced { issue } - } - - /// Refuse the request with a session error code and a reason for the peer. - 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; - } -} - -impl Drop for Scoped { - fn drop(&mut self) { - if let Some(issue) = self.issue.take() { - refuse(&issue, SessionError::Unauthorized, "unanswered".to_string()); - issue.lock().hold = None; - } - } -} - /// 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 @@ -1561,7 +1338,7 @@ impl RequestGrant { /// 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.path.clone(), held.kind, held.hold.clone()) + handle.verify_request(token, held.hold.clone()) } /// Apply a renewal's verdict, or return the refusal that ends the request. @@ -1661,16 +1438,20 @@ fn live(clock: &crate::time::Clock, kind: RequestKind, path: &str, grant: &Grant kind.covers(grant, path) && grant.expires.is_none_or(|expires| expires > clock.now()) } -/// A grant issued to one of the peer's session tokens, which the peer is told. Dropping it -/// ends the grant. +/// 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) { - update(&self.issue, grant); + let mut issue = self.issue.lock(); + if !issue.done { + issue.reply(Reply::Grant(grant)); + } } /// Revoke the grant with a session error code and a reason for the peer. @@ -1678,8 +1459,8 @@ 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 @@ -1688,45 +1469,7 @@ impl Issued { impl Drop for Issued { fn drop(&mut self) { - self.issue.lock().done = true; - } -} - -/// A grant one request holds on the token it carried, enforced by this side and never told -/// to the peer. Dropping it ends the request. -pub struct Enforced { - issue: kio::Shared, -} - -impl Enforced { - /// Replace the grant, such as with a lowered expiry. One that no longer covers the - /// request ends it. - pub fn update(&self, grant: Grant) { - update(&self.issue, grant); - } - - /// End the request with a session error code and a reason. - pub fn revoke(self, code: SessionError, reason: impl Into) { - refuse(&self.issue, code, reason.into()); - } - - /// Wait for the request to end ([`Error::Cancel`]), or for the session to close. - #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - closed(&self.issue).await - } -} - -impl Drop for Enforced { - fn drop(&mut self) { - self.issue.lock().done = true; - } -} - -fn update(issue: &kio::Shared, grant: Grant) { - let mut issue = issue.lock(); - if !issue.done { - issue.outbox.push_back(Reply::Grant(grant)); + self.issue.lock().finish(); } } @@ -1774,12 +1517,10 @@ pub(crate) struct Gate { #[derive(Clone, Copy)] #[cfg_attr(not(feature = "async"), allow(dead_code))] enum Part { - /// The peer's grant and the limit both. + /// The peer's grant and the scope both. Both, - /// The peer's grant alone: a token the peer presented stands in for the limit. + /// The peer's grant alone: a token the peer presented stands in for the scope. Granted, - /// The limit alone: a token we presented stands in for the peer's grant. - Limit, } impl Gate { @@ -1803,16 +1544,6 @@ impl Gate { } } - /// A gate on the limit alone, for our request our own token authorized: the peer - /// verifies the token, but a narrowed limit still ends the request. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn limit(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { - Self { - part: Part::Limit, - ..Self::new(handle, path, direction) - } - } - /// 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. @@ -1824,7 +1555,6 @@ impl Gate { let allowed = match self.part { Part::Both => permit.matches(path), Part::Granted => permit.granted(path), - Part::Limit => permit.within_limit(path), }; if !allowed { return Poll::Ready(()); @@ -1944,12 +1674,6 @@ impl Enforce { } } } - - /// Admit one broadcast [`Self::poll`] found uncovered, such as one a request token carries. - #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn admit(&mut self, path: crate::PathOwned) { - self.live.insert(path); - } } #[cfg(test)] @@ -1977,68 +1701,27 @@ mod tests { )); } - /// Request tokens queue apart from session tokens, so a backlog of one never refuses the - /// other. + /// Request tokens share the session tokens' bound, so a full queue refuses one. #[test] - fn request_tokens_are_bounded_apart_from_session_tokens() { + fn a_full_queue_refuses_a_request_token() { let handle = Handle::new(true); let _requests = handle.requests().unwrap(); - let mut scoped = handle.scoped().unwrap(); let queue = handle.acceptor().unwrap(); for _ in 0..128 { queue.push(Request::new(Bytes::new(), Default::default())); } let token = crate::setup::Token { - kind: 0, + kind: crate::setup::Token::OUT_OF_BAND, value: b"jwt".to_vec(), }; - let path = crate::Path::new("room").to_owned(); - let _verdict = handle.verify_request(token, path, RequestKind::Subscribe, None); - assert!( - matches!(scoped.poll_next(&kio::Waiter::noop()), Poll::Ready(Some(_))), - "a full session queue refused a request token" - ); - } - - /// Only an application that takes [`Handle::scoped`] verifies request tokens, and only once. - #[test] - fn request_tokens_are_opt_in() { - let handle = Handle::new(true); - let _requests = handle.requests().unwrap(); - let path = || crate::Path::new("room").to_owned(); - let token = || crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - }; - assert!( - !handle.token_decides(Direction::Publish), - "a session never limited and with no verifier ignores the token" - ); - let verdict = handle.verify_request(token(), path(), RequestKind::Subscribe, None); + let verdict = handle.verify_request(token, None); assert!(matches!( verdict.poll_grant(&kio::Waiter::noop()), Poll::Ready(Err(Refusal { - error: Error::Unsupported, + error: Error::Session(SessionError::TooManyRequests), .. })) )); - - let _scoped = handle.scoped().unwrap(); - assert!(matches!(handle.scoped(), Err(Error::Duplicate))); - assert!(handle.token_decides(Direction::Publish)); - - let limited = Handle::new(true); - limited.authorize(&Grant::all()); - assert!( - limited.token_decides(Direction::Publish), - "a limit, even one covering everything, leaves the token to decide" - ); - - let lite = Handle::new(false); - lite.lite(); - assert!(matches!(lite.scoped(), Err(Error::Unsupported))); - assert!(matches!(lite.set_request_token(Some(token())), Err(Error::Unsupported))); - lite.set_request_token(None).expect("clearing is always allowed"); } #[test] @@ -2152,51 +1835,61 @@ mod request_token_tests { crate::Path::new("room/alice").to_owned() } - /// A request token reaches the app's acceptor tagged with the request it rode on, and - /// the grant the app answers is what the verdict resolves to. + /// 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_with_its_context() { + async fn a_request_token_reaches_the_acceptor() { let handle = Handle::new(true); - let mut requests = handle.scoped().expect("take the requests"); - let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe, None); + 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(), &token(b"jwt", 7)); - assert_eq!(request.path(), &subscribe_path()); - assert_eq!(request.kind(), RequestKind::Subscribe); + 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" + ); + } + /// A request-borne token is verified even without the MoQ Auth extension: it rides the - /// request message, not the AUTH stream, so `scoped()` hands a live queue on a session - /// whose `supported` is false and the acceptor admits it. This is the shape a standard - /// moq-transport peer (an encoder or CDN) presents when it does not negotiate the moq-dev - /// AUTH extension. + /// 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.scoped().expect("take the requests"); - for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind, None); - let request = requests.next().await.expect("a request reaches the acceptor"); - assert_eq!(request.kind(), kind); - let _issued = request.accept(Grant::all()); - assert!( - verdict.grant().await.expect("granted").publish.matches("room/alice"), - "{kind:?} admitted without the AUTH extension" - ); - } + 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 `scoped()` consumer, a request token cannot be verified in band, so the verdict + /// 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), subscribe_path(), RequestKind::Subscribe, None); + 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:?}"); } @@ -2206,8 +1899,8 @@ mod request_token_tests { #[moq_net_sim::test] async fn a_refused_request_token_is_unauthorized() { let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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!( @@ -2221,15 +1914,15 @@ mod request_token_tests { #[moq_net_sim::test] async fn a_grant_ended_before_admission_is_refused() { let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); + let mut requests = handle.requests().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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", 1), subscribe_path(), RequestKind::Subscribe, None); + 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!( @@ -2237,7 +1930,7 @@ mod request_token_tests { "{err:?}" ); - let verdict = handle.verify_request(token(b"jwt", 2), subscribe_path(), RequestKind::Subscribe, None); + 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"); @@ -2252,8 +1945,8 @@ mod request_token_tests { #[moq_net_sim::test] async fn dropping_the_verdict_ends_the_grant() { let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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); @@ -2266,8 +1959,8 @@ mod request_token_tests { #[moq_net_sim::test] async fn a_request_token_never_joins_the_union() { let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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 @@ -2275,6 +1968,97 @@ mod request_token_tests { 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 { @@ -2290,8 +2074,8 @@ mod request_token_tests { async fn a_request_grant_has_the_acceptors_expiry() { let runtime = crate::time::Clock::sim(); let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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); @@ -2315,13 +2099,13 @@ mod request_token_tests { async fn an_already_expired_grant_is_refused() { let runtime = crate::time::Clock::sim(); let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); + 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), subscribe_path(), RequestKind::Subscribe, None); + 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( @@ -2337,7 +2121,7 @@ mod request_token_tests { "an expired grant admits nothing" ); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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( @@ -2349,7 +2133,7 @@ mod request_token_tests { None, ) .unwrap(); - let renewal = handle.verify_request(token(b"old2", 0), subscribe_path(), RequestKind::Subscribe, None); + let renewal = handle.verify_request(token(b"old2", 0), None); let _issued = requests.next().await.unwrap().accept(expired()); let renewed = renewal.grant().await; assert!( @@ -2363,9 +2147,9 @@ mod request_token_tests { 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.scoped().unwrap(); + let mut requests = handle.requests().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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); @@ -2381,7 +2165,7 @@ mod request_token_tests { .unwrap(); // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. - let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe, None); + 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); @@ -2401,8 +2185,8 @@ mod request_token_tests { 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.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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(); @@ -2429,8 +2213,8 @@ mod request_token_tests { async fn a_refused_renewal_leaves_the_grant() { let runtime = crate::time::Clock::sim(); let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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); @@ -2446,7 +2230,7 @@ mod request_token_tests { .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), subscribe_path(), RequestKind::Subscribe, None); + let renewal = handle.verify_request(token(b"bad", 0), None); requests .next() .await @@ -2475,8 +2259,8 @@ mod request_token_tests { 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.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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( @@ -2531,8 +2315,8 @@ mod request_token_tests { async fn an_acceptor_revoke_ends_the_request() { let runtime = crate::time::Clock::sim(); let handle = Handle::new(true); - let mut requests = handle.scoped().unwrap(); - let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe, None); + 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(); diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index 8785213272..037a095676 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -224,20 +224,10 @@ impl Writer { } impl Writer { - /// Encode an IETF `Message` into the write buffer whole, `[type_id][size][body]`, to be sent - /// by [`Self::poll_flush`]. An encode error leaves the buffer untouched. - pub fn buffer_message(&mut self, msg: &T) -> Result<(), Error> { - let version = self.version; - self.buffer_with(|w| { - w.varint(T::ID)?; - msg.encode(w, version) - }) - } - /// Encode an IETF `Message` to the stream, writing `[type_id][size][body]`. pub async fn encode_message(&mut self, msg: &T) -> Result<(), Error> { - self.buffer_message(msg)?; - std::future::poll_fn(|cx| self.poll_flush(cx)).await + self.buffer_varint(T::ID)?; + self.encode(msg).await } } diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index b3bfa2b375..03ab85c3f7 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -853,18 +853,7 @@ impl ControlStreamAdapter { let raw = encode_raw(type_id, &body, self.version); // Classify and route - let classified = classify(type_id, &body, self.version, &self.shared.namespaces)?; - // An answer to one of our updates names the update, which has no stream of its own, - // so it goes to the request the update targets. Only REQUEST_OK and REQUEST_ERROR - // answer an update. - if matches!(type_id, ietf::RequestOk::ID | ietf::RequestError::ID) - && let Route::Response(id) | Route::CloseStream(id) = classified - && let Some(target) = self.control.follow_up_target(id) - { - self.shared.push(target, raw); - continue; - } - match classified { + match classify(type_id, &body, self.version, &self.shared.namespaces)? { Route::NewRequest(request_id) => { let permit = self.control.accept(request_id)?; self.shared.open_incoming(request_id, raw, permit)? diff --git a/rs/moq-net/src/ietf/control.rs b/rs/moq-net/src/ietf/control.rs index 44bbf97394..c4a9bf0fed 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -81,8 +81,6 @@ struct ControlState { modern: Spent, /// The most runs `modern` may hold. Forgetting one would make its IDs usable again. history: u64, - /// Our outstanding updates, by their own Request ID, to the request each updates. - follow_ups: std::collections::HashMap, } /// The MAX_REQUEST_ID window we grant the peer. @@ -118,7 +116,6 @@ impl Control { // The peer takes the other parity: a client's IDs are even, a server's odd. modern: Spent::new(u64::from(client)), history: crate::session::Limits::default().requests(), - follow_ups: Default::default(), }), } } @@ -256,38 +253,6 @@ impl Control { } } -impl Control { - /// Route the answer to our update `update` back to the request `target` it updates, until - /// the returned guard drops. - /// - /// Drafts 15 and 16 name an update's own Request ID in its answer, and an update has no - /// stream of its own on the control stream. - pub fn follow_up(&self, update: RequestId, target: RequestId) -> FollowUp { - self.state.lock().follow_ups.insert(update, target); - FollowUp { - control: self.clone(), - update, - } - } - - /// The request an answer naming `id` belongs to, when `id` is an update of ours. - pub fn follow_up_target(&self, id: RequestId) -> Option { - self.state.lock().follow_ups.remove(&id) - } -} - -/// An update of ours awaiting its answer, routed back to the request it updates until dropped. -pub(super) struct FollowUp { - control: Control, - update: RequestId, -} - -impl Drop for FollowUp { - fn drop(&mut self) { - self.control.state.lock().follow_ups.remove(&self.update); - } -} - /// One request the peer holds open, retired (and eventually granted back) on drop. pub(super) struct RequestPermit(Control); @@ -301,21 +266,6 @@ impl Drop for RequestPermit { mod tests { use super::*; - /// An answer to our update maps to the request it updates once, and not after the - /// update stops waiting on it. - #[test] - fn an_update_answer_follows_its_request() { - let control = Control::new(None, true); - let answered = control.follow_up(RequestId(8), RequestId(2)); - assert_eq!(control.follow_up_target(RequestId(8)), Some(RequestId(2))); - assert_eq!(control.follow_up_target(RequestId(8)), None, "routed once"); - drop(answered); - - let abandoned = control.follow_up(RequestId(10), RequestId(2)); - drop(abandoned); - assert_eq!(control.follow_up_target(RequestId(10)), None, "dropped with its update"); - } - fn granted(control: &Control) -> Option { match control.poll_grant(&kio::Waiter::noop()) { Poll::Ready(max) => Some(max.0), diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1d3926497b..890122ce6e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -379,8 +379,6 @@ enum NamespaceEvent { Retry, /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. Regrant(crate::auth::Permit), - /// The client replaced its request token: re-present it on every live announce. - TokenRefresh(Option), } #[derive(Clone)] @@ -415,10 +413,6 @@ pub(super) struct Publisher { pub(super) subscriptions: crate::session::Slots, // Ranks every subscription's group streams against each other for the transport. priority: PriorityQueue, - // The AUTHORIZATION TOKEN this side presents on its PUBLISH_NAMESPACE requests and their - // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the - // session runs; the default presents none. A client credential. - request_token: crate::RequestToken, } struct Serve(Arc); @@ -493,7 +487,6 @@ where owed: Default::default(), subscriptions: Default::default(), priority: Default::default(), - request_token: crate::RequestToken::default(), } } @@ -503,15 +496,6 @@ where self } - /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the - /// PUBLISH_NAMESPACE requests this side sends, and on their REQUEST_UPDATEs, so a client - /// authorizes its announces the standard draft-17+ way (MoQ request-token). A shared handle, - /// so a replaced token is re-presented on each live announce (draft-17+). - pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { - self.request_token = token; - self - } - /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -816,12 +800,7 @@ where // once the limit does too. let (mut gate, request_grant) = match token { Some(token) => { - let verdict = self.auth.verify_request( - token, - msg.track_namespace.to_owned(), - crate::auth::RequestKind::Subscribe, - Some(slot.clone()), - ); + 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| { @@ -2475,19 +2454,7 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - // A request token authorizes an announce the connection grant does not cover, but only - // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE - // entry has no slot for one. So the grant still filters inline entries, and stands down - // only for requests, where the server's covers-gate refuses a bad token per request. - // The token never stands in for the limit this side set on the peer. A live request that - // presented one keeps standing on it after the token is cleared, as the peer still does. - let carries_token = matches!(ns.target, Target::Requests(_)) - && (self.request_token.peek().is_some() - || ns.requests.get(suffix).is_some_and(|request| request.backing.is_some())); - let permitted = match carries_token { - true => ns.permit.within_limit(path.as_str()), - false => ns.permitted(path), - }; + let permitted = ns.permitted(path); let Namespaces { peer, target, @@ -2622,9 +2589,6 @@ where return Ok(Refused::No); }; - // Backed from before the first byte, so grant enforcement never sees this advertisement - // carry a token it does not know about. - let (token, backing) = self.request_token.present(path); request.writer.varint(ietf::PublishNamespace::ID).await?; request .writer @@ -2632,7 +2596,7 @@ where request_id, track_namespace: path.as_path(), cluster, - authorization_token: token.map(crate::ietf::token::RequestToken), + authorization_token: None, }) .await?; @@ -2683,7 +2647,6 @@ where path: path.clone(), request_id, stream: request, - backing, }, ); Ok(Refused::No) @@ -2871,136 +2834,6 @@ where Ok(()) } - /// Re-present the client's request token on every live announce as a token-only - /// REQUEST_UPDATE (MoQ request-token renewal), so a refreshed credential reaches the peer - /// before the old grant lapses. - /// - /// Draft-16+ only: earlier drafts have no update for an advertisement, so a token set on - /// them rides the initial advertisement and is not renewed in place. Clearing the token (`None`) - /// is not a renewal and sends nothing. A refusal withdraws the announce, since the receiver - /// closes its stream on a renewal it cannot grant; the retry re-offers it carrying the new - /// token, honoring any retry interval the refusal named. - async fn refresh_request_token( - &self, - ns: &mut Namespaces, - token: Option, - ) -> Result<(), Error> { - let Some(token) = token else { - return Ok(()); - }; - if matches!(self.version, Version::Draft14 | Version::Draft15) { - return Ok(()); - } - - // Only an advertisement that carried a token stands on one: the rest stand on the - // connection grant, and a token arriving on them would be refused unread. - let suffixes: Vec = ns - .requests - .iter() - .filter(|(_, request)| request.backing.is_some()) - .map(|(suffix, _)| suffix.clone()) - .collect(); - for suffix in suffixes { - let request_id = self.control.next_request_id(&self.runtime).await?; - let Some(request) = ns.requests.get_mut(&suffix) else { - continue; - }; - // Draft-16 names the advertisement in the update, and its answer names the update, - // which the control stream routes back here while this is held. - let legacy = self.version == Version::Draft16; - let _follow_up = legacy.then(|| self.control.follow_up(request_id, request.request_id)); - let update = ietf::PublishNamespaceUpdate { - request_id, - namespace_request_id: legacy.then_some(request.request_id), - hops: None, - cost: None, - authorization_token: Some(crate::ietf::token::RequestToken(token.clone())), - }; - let answer = match request.stream.writer.encode_message(&update).await { - Ok(()) => self.read_response(&mut request.stream).await, - Err(err) => Err(err), - }; - - let absolute = self.origin.absolute(&request.path).to_owned(); - // The old grant can lapse while this waits, and the peer then ends only this - // advertisement: by never answering, closing the stream, or (draft-16) sending - // PUBLISH_NAMESPACE_CANCEL on it. That drops the advertisement, never the session. - let answer = match answer { - Ok(Some((type_id, _))) if type_id == ietf::PublishNamespaceCancel::ID && legacy => { - tracing::debug!(broadcast = %absolute, "advertisement cancelled during the token refresh"); - None - } - Ok(Some(answer)) => Some(answer), - Ok(None) => { - tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); - None - } - // A malformed answer is the peer's violation, not a closed stream, and ends the - // session. A stream that ends mid-message reads as Short, which is a closure. - Err(err) - if super::subscriber::is_protocol_violation(&err) - && !matches!(err, Error::Decode(DecodeError::Short)) => - { - return Err(err); - } - Err(err) => { - tracing::debug!(broadcast = %absolute, %err, "advertisement ended during the token refresh"); - None - } - }; - let Some((type_id, body)) = answer else { - ns.requests.remove(&suffix); - // Forget what was sent so reconcile re-offers it with the new token, and re-arm - // the deferred-only retry sweep if an earlier refusal still holds it back. - if let Some(watch) = ns.watched.get_mut(&suffix) { - watch.sent = Advert::None; - watch.deferred = watch.refused.pending(); - } - continue; - }; - let mut data = body.decoder(self.version); - match type_id { - ietf::RequestOk::ID => { - let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; - if !data.is_empty() { - return Err(Error::WrongSize); - } - tracing::debug!(broadcast = %absolute, "request token refreshed"); - if let Some(request) = ns.requests.get_mut(&suffix) { - request - .backing - .get_or_insert_with(|| self.request_token.back(request.path.clone())); - } - } - ietf::RequestError::ID => { - let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; - if !data.is_empty() { - return Err(Error::WrongSize); - } - // A refused renewal means the receiver closed its stream, so the announce - // cannot stand on the old grant: withdraw it and honor the refusal's retry - // interval, exactly as a repriced update does. The retry re-offers it fresh, - // carrying the new token. - tracing::warn!(broadcast = %absolute, message = ?msg, "request token refresh refused"); - let refused = self.refusal(msg.retry_interval); - let deferred = refused.pending(); - self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) - .await?; - if let Some(watch) = ns.watched.get_mut(&suffix) { - watch.refused = refused; - watch.sent = Advert::None; - // Re-arm the retry sweep (which only revisits deferred namespaces) so a - // bounded refusal re-offers this fresh with the new token once its interval - // passes; a permanent refusal (Never) is not pending and stays withdrawn. - watch.deferred = deferred; - } - } - _ => return Err(Error::UnexpectedMessage), - } - } - Ok(()) - } - /// Close out every open PUBLISH_NAMESPACE request. A no-op for a loop whose entries /// ride the SUBSCRIBE_NAMESPACE stream itself, which retracts them by ending. async fn withdraw_requests( @@ -3230,10 +3063,6 @@ where kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; } (ns.permit, ns.epoch) = self.permit_now(); - // The request token presented on these announces, read before the initial set goes - // out: a client replacing it mid-way wakes the loop, which re-presents the new one on - // each live announce. - let mut last_token = self.request_token.peek(); for update in initial { self.apply_update(&mut ns, &prefix, update, true).await?; } @@ -3283,11 +3112,6 @@ where crate::announce::Event::End(update) => (update, false, false), }))); } - // A replaced request token is re-presented on each live announce, below the - // origin updates so a busy loop still makes progress on both. - if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { - return Poll::Ready(NamespaceEvent::TokenRefresh(token)); - } if retry.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Retry); } @@ -3298,17 +3122,6 @@ where match event { NamespaceEvent::Closed(res) => break res, - NamespaceEvent::TokenRefresh(token) => { - last_token = token.clone(); - self.refresh_request_token(&mut ns, token).await?; - // What the token now carries or no longer carries changes what may be offered: a - // namespace withheld for lack of a grant goes out once a token can carry it. - let suffixes: Vec = ns.watched.keys().cloned().collect(); - for suffix in suffixes { - let path = prefix.join(&suffix); - self.sync_namespace(&mut ns, &suffix, &path).await?; - } - } NamespaceEvent::Retry => { retry_at = None; retry_delay = (retry_delay * 2).min(RETRY_MAX); @@ -4241,9 +4054,6 @@ struct NamespaceRequest { path: crate::PathOwned, request_id: RequestId, stream: Stream, - /// Set once the advertisement presented a request token, so the peer holds it on that - /// token's grant rather than the connection grant. - backing: Option, } #[cfg(test)] @@ -8344,7 +8154,7 @@ mod serve_tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); let auth = crate::auth::Handle::new(false); - let mut requests = auth.scoped().unwrap(); + 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( @@ -8473,7 +8283,7 @@ mod serve_tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); let auth = crate::auth::Handle::new(false); - let mut requests = auth.scoped().unwrap(); + 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( @@ -8960,13 +8770,9 @@ mod serve_tests { /// 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, - ) { + fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Token) { let auth = crate::auth::Handle::new(true); - let requests = auth.scoped().unwrap(); + 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()); @@ -8981,200 +8787,11 @@ mod serve_tests { ); (auth, requests, cred) } - - /// A token-bearing client does not self-censor on its connection grant: the - /// publisher advertises a namespace its connection grant does not cover, carrying the token, - /// and the server's covers-gate is left to be the authority. A token-less client with the same - /// grant filters that announce, as before. - #[moq_net_sim::test] - async fn a_token_bearing_client_announces_outside_its_connection_grant() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - // A connection grant of "other", which does not cover "cam". The presented credential and - // its grant are held for the run's lifetime, else the union reverts to permissive. - fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { - 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 { - publish: crate::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - (auth, cred) - } - - // With a token, "cam" is advertised despite the grant not covering it (the token rides it). - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let (auth, _cred) = seed_auth(); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_auth(auth) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - let mut advertised = false; - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) >= 1 { - advertised = true; - break; - } - settle().await; - } - assert!( - advertised, - "a token-bearing client must announce outside its connection grant" - ); - - // Without a token, the same grant filters "cam": nothing is advertised. - let origin2 = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam2 = origin2.announce("cam", crate::origin::Route::default()).unwrap(); - let session2 = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log2 = session2.log.clone(); - let peer_setup2 = peer::PeerSetup::default(); - peer_setup2.set(peer::Peer::default()); - let (auth2, _cred2) = seed_auth(); - let publisher2 = Publisher::new( - crate::time::Clock::sim(), - session2, - origin2.consume(), - Control::new(None, false), - None, - peer_setup2, - VERSION, - ) - .with_auth(auth2); - let mut run2 = std::pin::pin!(publisher2.run_publish_namespaces()); - for _ in 0..80 { - assert!(futures::poll!(run2.as_mut()).is_pending()); - settle().await; - } - assert_eq!( - occurrences(&log2, b"cam"), - 0, - "a token-less client self-censors on its grant" - ); - } - - /// A request token stands in for the connection grant, never for the limit this side set - /// on the peer: a token-bearing client still withholds an announce outside that limit. - #[moq_net_sim::test] - async fn a_request_token_does_not_lift_the_local_limit_on_announces() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let auth = crate::auth::Handle::new(false); - auth.authorize(&crate::auth::Grant::default()); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_auth(auth) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 0, "the limit still withholds the announce"); - } - - /// A peer that requires solicitation gets inline NAMESPACE entries, which have no slot for a - /// request token, so the token cannot authorize them: the connection grant still filters the - /// answer to its SUBSCRIBE_NAMESPACE. - #[moq_net_sim::test] - async fn a_request_token_does_not_lift_the_grant_on_inline_namespaces() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - let _other = origin.announce("other/mic", crate::origin::Route::default()).unwrap(); - settle().await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - solicit: Some(true), - ..Default::default() - }); - 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 { - publish: crate::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_auth(auth) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - - let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(""), - hidden: false, - }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); - let mut covered = false; - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"mic") >= 1 { - covered = true; - break; - } - settle().await; - } - assert!(covered, "a namespace the grant covers is advertised inline"); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 0, "the grant still filters inline entries"); - assert_eq!(occurrences(&log, &token), 0, "an inline entry carries no token"); - } - + /// 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: 300, + kind: crate::setup::Token::OUT_OF_BAND, value: vec![0x00, 0xff], }) } @@ -10239,7 +9856,7 @@ mod serve_tests { 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.scoped().unwrap(); + let mut requests = auth.requests().unwrap(); auth.authorize(&crate::auth::Grant::default()); let h = serve_with_auth(VERSION, auth, Vec::new()); @@ -10290,7 +9907,7 @@ mod serve_tests { ) -> (crate::auth::Handle, Serve, Serving) { const VERSION: Version = Version::Draft18; let auth = crate::auth::Handle::new(false); - let mut requests = auth.scoped().unwrap(); + let mut requests = auth.requests().unwrap(); if let Some(limit) = &limit { auth.authorize(limit); } @@ -10380,7 +9997,7 @@ mod serve_tests { 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.scoped().unwrap(); + 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()); @@ -10552,7 +10169,7 @@ mod serve_tests { // Withdrawn before the application looked. let auth = crate::auth::Handle::new(false); - let _requests = auth.scoped().unwrap(); + 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(); @@ -10574,7 +10191,7 @@ mod serve_tests { // Withdrawn after the application took it. let auth = crate::auth::Handle::new(false); - let mut requests = auth.scoped().unwrap(); + 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(); @@ -10614,7 +10231,7 @@ mod serve_tests { 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.scoped().unwrap(); + let mut requests = auth.requests().unwrap(); let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); auth.granted( 0, @@ -10660,15 +10277,6 @@ mod serve_tests { .await } } - - /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. - fn typed(raw: &[u8]) -> crate::setup::Token { - assert_eq!(raw[0], 0x03, "USE_VALUE"); - crate::setup::Token { - kind: raw[1].into(), - value: raw[2..].to_vec(), - } - } } #[cfg(test)] @@ -12632,957 +12240,6 @@ mod tests { } } } - - /// A client configured with a request token puts it on the PUBLISH_NAMESPACE it sends, on - /// a legacy draft (draft-14 trailing block) and a strict one (draft-18 message - /// parameters). Without `with_request_token` the token bytes never reach the wire. - #[moq_net_sim::test] - async fn a_configured_request_token_rides_the_publish_namespace() { - let token = bytes::Bytes::from_static(&[0x03, 0x00, 0xfe, 0xed]); - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - ] { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(version).await]); - 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, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - version, - ) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - let mut sent = false; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) >= 1 { - sent = true; - break; - } - settle().await; - } - assert!(sent, "{version}: the request token must ride the PUBLISH_NAMESPACE"); - } - } - - /// Replacing the request token re-presents it on a live announce as a token-only - /// PUBLISH_NAMESPACE_UPDATE (MoQ request-token renewal), on the same stream, without a - /// reprice. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. - #[moq_net_sim::test] - async fn setting_a_new_request_token_re_presents_it_on_a_live_announce() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // One stream carries the announce: RequestOk answers the PUBLISH_NAMESPACE, then - // RequestOk answers the token-only REQUEST_UPDATE. - let mut script = publish_namespace_ok(VERSION).await; - script.extend(publish_namespace_ok(VERSION).await); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - // The initial token rides the PUBLISH_NAMESPACE. - let mut initial = false; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - initial = true; - break; - } - settle().await; - } - assert!(initial, "the initial token must ride the PUBLISH_NAMESPACE"); - - // Replacing it re-presents the new token on the live announce. - token.set(Some(typed(&second))); - let mut renewed = false; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 1 { - renewed = true; - break; - } - settle().await; - } - assert!(renewed, "a replaced token must be re-presented on the live announce"); - } - - /// A namespace advertised without a token stands on the connection grant, so setting a - /// token later re-presents nothing on it: the receiver would refuse a token on an - /// advertisement it admitted without one. - #[moq_net_sim::test] - async fn a_token_set_later_never_renews_a_tokenless_announce() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - let mut script = publish_namespace_ok(VERSION).await; - script.extend(publish_namespace_ok(VERSION).await); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let request_token = crate::RequestToken::new(None); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(request_token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - let mut announced = false; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if !log.writes.lock().unwrap().is_empty() { - announced = true; - break; - } - settle().await; - } - assert!(announced, "the namespace is advertised without a token"); - - request_token.set(Some(typed(&token))); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, &token), 0, "the token renewed a tokenless announce"); - } - - /// A token replaced while the initial PUBLISH_NAMESPACE awaits its answer is re-presented - /// once that answer arrives, rather than counted as the token the announce carried. - #[moq_net_sim::test] - async fn a_token_replaced_during_the_initial_announce_is_re_presented() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // The peer holds its answer until the test pushes it. - let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, &first), - 1, - "the initial token rides the PUBLISH_NAMESPACE" - ); - - token.set(Some(typed(&second))); - let mut answers = publish_namespace_ok(VERSION).await; - answers.extend(publish_namespace_ok(VERSION).await); - session.push(&answers); - let mut renewed = false; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 1 { - renewed = true; - break; - } - settle().await; - } - assert!(renewed, "a token replaced mid-announce must be re-presented"); - } - - /// Setting the same token again is a no-op: no second REQUEST_UPDATE is sent, so the token - /// bytes appear once (the initial PUBLISH_NAMESPACE) and no more. - #[moq_net_sim::test] - async fn an_unchanged_token_is_not_re_presented() { - const VERSION: Version = Version::Draft18; - let token_bytes = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - let mut script = publish_namespace_ok(VERSION).await; - script.extend(publish_namespace_ok(VERSION).await); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&token_bytes))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token_bytes) >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, &token_bytes), - 1, - "the initial token rode the announce once" - ); - - // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. - token.set(Some(typed(&token_bytes))); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!( - occurrences(&log, &token_bytes), - 1, - "an unchanged token must not be re-presented" - ); - } - - /// A refused token refresh (REQUEST_ERROR with a retry interval) withdraws the announce and - /// re-offers it fresh, carrying the new token, once the interval passes. Without re-arming the - /// retry sweep (which only revisits deferred namespaces) the withdrawn namespace would never - /// come back. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. - #[moq_net_sim::test] - async fn a_refused_token_refresh_re_offers_the_namespace_with_the_new_token() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is - // refused with a 1s retry interval. Stream 2: the re-offer is accepted. - let mut stream1 = publish_namespace_ok(VERSION).await; - stream1.extend(publish_namespace_error(VERSION, 1000).await); - let stream2 = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - - // Replace the token: the refresh REQUEST_UPDATE carries it and the peer refuses it, - // closing the stream. - token.set(Some(typed(&second))); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, &second), - 1, - "the refused refresh carried the new token once" - ); - - // Past the 1s retry interval, the withdrawn namespace is re-offered on a fresh stream, - // carrying the new token. - moq_net_sim::advance(Duration::from_secs(2)).await; - let mut reoffered = false; - for _ in 0..300 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 2 { - reoffered = true; - break; - } - settle().await; - } - assert!( - reoffered, - "a refused refresh must re-offer the namespace with the new token after the interval" - ); - assert!( - log.bi_opens() >= 2, - "the re-offer opened a fresh PUBLISH_NAMESPACE stream" - ); - } - - /// A token refresh the peer never answers drops the announce's stream and re-offers the - /// namespace fresh, carrying the new token. Without forgetting the sent advertisement, - /// reconcile would see it unchanged and the namespace would stay withdrawn. Draft-17+, - /// since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. - #[moq_net_sim::test] - async fn an_unanswered_token_refresh_re_offers_the_namespace_with_the_new_token() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is - // never answered. Stream 2: the re-offer is accepted. - let stream1 = publish_namespace_ok(VERSION).await; - let stream2 = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - - token.set(Some(typed(&second))); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, &second), - 1, - "the unanswered refresh carried the new token once" - ); - - // Past the answer timeout, the namespace is re-offered on a fresh stream with the new token. - moq_net_sim::advance(ADVERTISE_TIMEOUT + Duration::from_secs(1)).await; - let mut reoffered = false; - for _ in 0..300 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &second) >= 2 { - reoffered = true; - break; - } - settle().await; - } - assert!( - reoffered, - "an unanswered refresh must re-offer the namespace with the new token" - ); - assert!( - log.bi_opens() >= 2, - "the re-offer opened a fresh PUBLISH_NAMESPACE stream" - ); - } - - /// A peer that ends the advertisement while a token refresh waits for its answer (its old - /// grant lapsed) ends only that advertisement: the namespace is re-offered with the new - /// token and the session stays up. Draft-16 cancels it on the stream, draft-17+ closes it. - #[moq_net_sim::test] - async fn an_advertisement_ended_during_a_token_refresh_is_re_offered() { - for version in [Version::Draft16, Version::Draft18] { - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // Stream 1: the PUBLISH_NAMESPACE is accepted, then the peer ends it. Stream 2: the - // re-offer is accepted. - let mut stream1 = publish_namespace_ok(version).await; - let session = match version { - Version::Draft16 => { - let cancel = crate::lite::test_transport::Log::default(); - crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(cancel.clone()), version) - .encode_message(&ietf::PublishNamespaceCancel { - track_namespace: crate::Path::new("cam"), - request_id: RequestId(0), - error_code: 0x1, - reason_phrase: "expired".into(), - }) - .await - .unwrap(); - stream1.extend(cancel.writes.lock().unwrap().iter()); - crate::lite::test_transport::ScriptedSession::per_stream(vec![ - stream1, - publish_namespace_ok(version).await, - ]) - } - _ => crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ - stream1, - publish_namespace_ok(version).await, - ]), - }; - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - version, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!( - occurrences(&log, &first), - 1, - "{version}: the initial token rode the announce" - ); - - token.set(Some(typed(&second))); - let mut reoffered = false; - for _ in 0..300 { - assert!( - futures::poll!(run.as_mut()).is_pending(), - "{version}: an ended advertisement must not end the namespace loop" - ); - if occurrences(&log, &second) >= 2 { - reoffered = true; - break; - } - settle().await; - } - assert!( - reoffered, - "{version}: the ended namespace is re-offered with the new token" - ); - assert!(log.closes().is_empty(), "{version}: the session stays up"); - } - } - - /// A malformed answer to a token refresh is the peer's violation, not a closed stream: it - /// still ends the namespace loop, and with it the session. - #[moq_net_sim::test] - async fn a_malformed_token_refresh_answer_stays_fatal() { - const VERSION: Version = Version::Draft17; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // The PUBLISH_NAMESPACE is accepted, then the renewal is answered with a message type in - // the seven-byte varint form draft-17 forbids. - let mut stream1 = publish_namespace_ok(VERSION).await; - stream1.extend([0xfc, 0, 0, 0, 0, 0, 0]); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - - token.set(Some(typed(&second))); - 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(Err(Error::Decode(DecodeError::InvalidValue)))), - "a malformed answer must stay fatal, got {result:?}" - ); - } - - /// A token refresh answered with a REQUEST_OK that carries junk inside its declared size - /// ends the session, rather than marking the advertisement as standing on the new token. - #[moq_net_sim::test] - async fn a_token_refresh_answer_with_trailing_bytes_is_fatal() { - const VERSION: Version = Version::Draft17; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - settle().await; - - // The PUBLISH_NAMESPACE is accepted, then the renewal is answered by a REQUEST_OK whose - // declared size covers one byte more than the message. - let ok = publish_namespace_ok(VERSION).await; - assert_eq!( - ok[0], - ietf::RequestOk::ID as u8, - "a one-byte type, then a two-byte size" - ); - let mut padded = ok.clone(); - let size = u16::from_be_bytes([padded[1], padded[2]]) + 1; - padded[1..3].copy_from_slice(&size.to_be_bytes()); - padded.push(0x00); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, padded].concat()]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - - let token = crate::RequestToken::new(Some(typed(&first))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_request_token(token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - - token.set(Some(typed(&second))); - 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(Err(Error::WrongSize))), - "an answer with trailing bytes must be fatal, got {result:?}" - ); - } - - /// A token-bearing client's reprice carries only the cluster change, never the unchanged - /// token: a receiver renewing a token-authorized announce answers a token-bearing update as - /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. - #[moq_net_sim::test] - async fn a_reprice_does_not_carry_the_request_token() { - const VERSION: Version = Version::Draft19; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - // Forward the reprice at once: the update hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - // One epoch, so the warm route re-prices the instance rather than restart it. - let epoch = crate::Epoch::mint(); - let _cold = origin - .announce( - "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), - ) - .unwrap(); - settle().await; - - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); - let log = session.log.clone(); - - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - clustered(Some(false)), - VERSION, - ) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); - - // The announce has gone out carrying the token exactly once; a reprice must then emit a - // PUBLISH_NAMESPACE_UPDATE that does NOT re-send the token. - let after_announce = log.writes.lock().unwrap().len(); - let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) - .unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if log.writes.lock().unwrap().len() > after_announce { - break; - } - settle().await; - } - assert!( - log.writes.lock().unwrap().len() > after_announce, - "the reprice update was never sent" - ); - assert_eq!( - occurrences(&log, &token), - 1, - "the reprice must not carry the request token: it rides only the initial announce" - ); - } - - /// Clearing the request token leaves a live advertisement on the token it presented: a - /// later reprice of a namespace outside the connection grant updates it in place rather - /// than withdrawing what the peer still holds on that token. - #[moq_net_sim::test] - async fn a_cleared_token_keeps_its_live_announce() { - const VERSION: Version = Version::Draft19; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - // Forward the reprice at once: the update hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - // One epoch, so the warm route re-prices the instance rather than restart it. - let epoch = crate::Epoch::mint(); - let _cold = origin - .announce( - "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), - ) - .unwrap(); - settle().await; - - // A connection grant of "other", which does not cover "cam". - 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 { - publish: crate::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); - let log = session.log.clone(); - let request_token = crate::RequestToken::new(Some(typed(&token))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - clustered(Some(false)), - VERSION, - ) - .with_auth(auth) - .with_request_token(request_token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - - request_token.set(None); - let after_announce = log.writes.lock().unwrap().len(); - let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) - .unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if log.writes.lock().unwrap().len() > after_announce { - break; - } - settle().await; - } - assert!(log.resets().is_empty(), "the live announce was withdrawn"); - assert!( - log.writes.lock().unwrap().len() > after_announce, - "the reprice update was never sent" - ); - } - - /// A namespace withheld for lack of a grant goes out as soon as a request token is set - /// that can carry it, not on the next unrelated route or grant change. - #[moq_net_sim::test] - async fn setting_a_token_offers_a_withheld_namespace() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - // A connection grant of "other", which does not cover "cam". - 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 { - publish: crate::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let request_token = crate::RequestToken::new(None); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_auth(auth) - .with_request_token(request_token.clone()); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..80 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 0, "withheld without a token"); - - request_token.set(Some(typed(&token))); - for _ in 0..80 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) > 0 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &token), 1, "the token carries the withheld namespace"); - } - - /// A token cleared while an advertisement outside the grant waits for its stream leaves - /// it carrying none: it is withdrawn, and setting a token again offers it afresh. - #[moq_net_sim::test] - async fn a_token_cleared_before_the_advertisement_goes_out_withdraws_it() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - // A connection grant of "other", which does not cover "cam". - 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 { - publish: crate::Pattern::subtree("other").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let ok = publish_namespace_ok(VERSION).await; - let open = kio::Producer::new(false); - let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], open.consume()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let request_token = crate::RequestToken::new(Some(typed(&token))); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ) - .with_auth(auth) - .with_request_token(request_token.clone()); - - // Offered under the token, then parked opening its stream while the token is cleared. - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert!(log.writes.lock().unwrap().is_empty(), "the advertisement was not held"); - request_token.set(None); - let Ok(mut opened) = open.write() else { - panic!("stream gate closed"); - }; - *opened = true; - drop(opened); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if !log.resets().is_empty() { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement went out"); - assert_eq!(occurrences(&log, &token), 0, "carrying no token"); - assert!(!log.resets().is_empty(), "and was withdrawn"); - - request_token.set(Some(typed(&token))); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &token) > 0 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &token), 1, "a token set again offers it afresh"); - } - - /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. - fn typed(raw: &[u8]) -> crate::setup::Token { - assert_eq!(raw[0], 0x03, "USE_VALUE"); - crate::setup::Token { - kind: raw[1].into(), - value: raw[2..].to_vec(), - } - } } /// The live edge a SUBSCRIBE resolves against, snapshotted once so the subscription diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 4530098e1a..997fdeeb27 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -133,7 +133,6 @@ where early_unis, extensions, } = config; - let request_token = auth.request_token(); // GOAWAY wiring: the public Session holds one half (drain trigger, received // signal), the protocol tasks below hold the other. @@ -227,8 +226,7 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()) - .with_request_token(request_token.clone()); + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -248,7 +246,6 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) - .with_request_token(request_token.clone()) .with_solicit(extensions.solicit); subscriber.announces = announces; @@ -416,11 +413,9 @@ where let auth = auth.clone(); let origin = publish.clone(); let session = session.clone(); - let request_token = request_token.clone(); - let peer_setup = peer_setup.clone(); async move { match client { - true => enforce_grant(auth, origin, session, request_token, peer_setup, version).await, + true => enforce_grant(auth, origin, session).await, false => std::future::pending().await, } } @@ -434,8 +429,7 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()) - .with_request_token(request_token.clone()); + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -455,7 +449,6 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) - .with_request_token(request_token.clone()) .with_solicit(extensions.solicit); // Our tokens, one Auth request each, once the peer's SETUP negotiates it. @@ -1421,31 +1414,11 @@ async fn enforce_grant( auth: crate::auth::Handle, origin: origin::Consumer, mut session: S, - request_token: crate::RequestToken, - peer_setup: peer::PeerSetup, - version: Version, ) -> Result<(), Error> { - // A request token authorizes an announce outside the connection grant only when the - // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on - // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every - // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still - // bounds what we publish. The token can be set or cleared at any time, so it is read when - // an uncovered broadcast turns up: one is admitted while a token is set, or once cleared, if - // its live advertisement already presented one, since that still stands on it. The - // publisher marks that before the first byte, so this cursor lagging behind its own never - // mistakes a token-bearing advertisement for an unauthorized one. let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); - let path = loop { - let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { - return Ok(()); - }; - let set = request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await; - if set || request_token.backs(&path) { - check.admit(path); - continue; - } - break path; + let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { + return Ok(()); }; tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); let err = Error::Unauthorized; @@ -1456,16 +1429,6 @@ async fn enforce_grant( Err(err) } -/// Whether our announces will ride their own PUBLISH_NAMESPACE requests, the only form that -/// carries a request token, rather than inline NAMESPACE entries. Draft-14/15 predate -/// NAMESPACE; later drafts send requests unless the peer requires solicitation. -async fn announces_carry_token(peer_setup: &peer::PeerSetup, version: Version) -> bool { - match version { - Version::Draft14 | Version::Draft15 => true, - _ => !peer_setup.get().await.solicit.unwrap_or(false), - } -} - #[cfg(test)] mod tests { use super::*; @@ -1929,228 +1892,6 @@ mod tests { /// parks again; a busy machine cannot turn a slow announce into a passing silence. const ANNOUNCE_TURNS: usize = 100; - /// A client that presents a request token authorizes each announce at the server when the - /// announce carries the token, so dialing-side grant enforcement stands down for a peer - /// that takes unsolicited PUBLISH_NAMESPACE requests: enforcing it would close the client - /// for announcing outside the connection grant the token was meant to extend. - #[moq_net_sim::test] - async fn a_client_may_send_a_token_bearing_request_its_connection_grant_does_not_cover() { - let auth = crate::auth::Handle::new(true); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("room/alice", crate::origin::Route::default()).unwrap(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - solicit: Some(false), - ..Default::default() - }); - - let mut enforce = std::pin::pin!(enforce_grant( - auth, - origin.consume(), - session, - crate::RequestToken::new(Some(crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - })), - peer_setup, - Version::Draft18, - )); - for _ in 0..ANNOUNCE_TURNS { - assert!( - futures::poll!(enforce.as_mut()).is_pending(), - "a token-bearing client must not be closed by grant enforcement" - ); - moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; - } - assert!( - log.closes().is_empty(), - "the session must stay open for a token-bearing client" - ); - } - - /// A request token set after the session started stands enforcement down just the same: - /// the announce that follows carries it, so the peer decides, not the connection grant. - #[moq_net_sim::test] - async fn a_request_token_stands_enforcement_down_until_cleared() { - let auth = crate::auth::Handle::new(true); - let setup = auth.present(bytes::Bytes::new(), true).unwrap(); - auth.granted( - 0, - crate::auth::Grant { - publish: crate::Pattern::subtree("room/alice").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - solicit: Some(false), - ..Default::default() - }); - let request_token = crate::RequestToken::new(None); - - let mut enforce = std::pin::pin!(enforce_grant( - auth, - origin.consume(), - session, - request_token.clone(), - peer_setup, - Version::Draft18, - )); - assert!(futures::poll!(enforce.as_mut()).is_pending(), "nothing to enforce yet"); - - request_token.set(Some(crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - })); - let _cam = origin - .announce("room/bob/cam", crate::origin::Route::default()) - .unwrap(); - for _ in 0..ANNOUNCE_TURNS { - assert!( - futures::poll!(enforce.as_mut()).is_pending(), - "enforcement closed the session" - ); - moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; - } - assert!( - log.closes().is_empty(), - "the session stays open for the token-bearing announce" - ); - - // Clearing the token leaves that announce standing on it, but the next one outside the - // grant carries no token, so the grant decides again. - request_token.set(None); - for _ in 0..ANNOUNCE_TURNS { - assert!( - futures::poll!(enforce.as_mut()).is_pending(), - "an announce made under the token was rechecked" - ); - moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; - } - let _carol = origin - .announce("room/carol/cam", crate::origin::Route::default()) - .unwrap(); - let result = moq_net_sim::timeout(std::time::Duration::from_secs(1), enforce) - .await - .expect("clearing the token never resumed enforcement"); - assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); - assert_eq!(log.closes().len(), 1, "the session closes for the token-less announce"); - drop(setup); - } - - /// The publisher's announce cursor can present the token on a broadcast before this - /// cursor reaches it, and the token can be cleared in between: the advertisement still - /// stands on the token it carried, so enforcement admits it rather than close the session. - #[moq_net_sim::test] - async fn a_token_cleared_behind_a_presented_announce_keeps_the_session() { - let auth = crate::auth::Handle::new(true); - let setup = auth.present(bytes::Bytes::new(), true).unwrap(); - auth.granted( - 0, - crate::auth::Grant { - publish: crate::Pattern::subtree("room/alice").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - solicit: Some(false), - ..Default::default() - }); - let request_token = crate::RequestToken::new(Some(crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - })); - - let mut enforce = std::pin::pin!(enforce_grant( - auth, - origin.consume(), - session, - request_token.clone(), - peer_setup, - Version::Draft18, - )); - assert!(futures::poll!(enforce.as_mut()).is_pending(), "nothing to enforce yet"); - - // Between two polls of this loop: the broadcast appears, the publisher advertises it - // carrying the token, and the token is cleared. - let _bob = origin - .announce("room/bob/cam", crate::origin::Route::default()) - .unwrap(); - let (presented, backing) = request_token.present(&crate::Path::new("room/bob/cam").to_owned()); - assert!( - presented.is_some() && backing.is_some(), - "the advertisement carries the token" - ); - request_token.set(None); - - for _ in 0..ANNOUNCE_TURNS { - assert!( - futures::poll!(enforce.as_mut()).is_pending(), - "enforcement closed over a token-bearing advertisement" - ); - moq_net_sim::sleep(std::time::Duration::from_millis(1)).await; - } - assert!(log.closes().is_empty(), "the session stays open"); - drop(backing); - drop(setup); - } - - /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, - /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client - /// advertise outside its connection grant there: the grant is still enforced. - #[moq_net_sim::test] - async fn a_request_token_does_not_lift_the_grant_when_announces_are_inline() { - let auth = crate::auth::Handle::new(true); - let setup = auth.present(bytes::Bytes::new(), true).unwrap(); - auth.granted( - 0, - crate::auth::Grant { - publish: crate::Pattern::subtree("room/alice").unwrap().into(), - subscribe: crate::Patterns::new(), - expires: None, - }, - ); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin - .announce("room/bob/cam", crate::origin::Route::default()) - .unwrap(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); - let log = session.log.clone(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - solicit: Some(true), - ..Default::default() - }); - - let result = enforce_grant( - auth, - origin.consume(), - session, - crate::RequestToken::new(Some(crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - })), - peer_setup, - Version::Draft18, - ) - .await; - - assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); - assert_eq!(log.closes().len(), 1, "the session closes on the uncovered announce"); - drop(setup); - } - /// Run a publish-only session against a peer that declared `peer_declared`, returning /// how many times the namespace reached the wire. /// diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index a5ab5267d1..8cafb37902 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -642,10 +642,6 @@ pub(super) struct Subscriber { frames: frame::Budget, // Namespaces the peer may have announced at once (`session::Limits::announces`). pub(super) announces: crate::session::Slots, - // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests and their - // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the - // session runs; the default presents none. A client credential. - request_token: crate::RequestToken, } /// The prefixes to issue SUBSCRIBE_NAMESPACE for: `origin`'s permitted scope, @@ -695,12 +691,6 @@ async fn resolve_track_alias( .await } -/// Wait for a request ID to carry a subscription's token renewal. Owns its handles, so the -/// subscription loop can hold the wait across turns and drop it at any of them. -async fn request_credit(control: Control, runtime: crate::time::Clock) -> Result { - control.next_request_id(&runtime).await -} - impl Subscriber where S: crate::transport::poll::Boxable, @@ -737,7 +727,6 @@ where frames: Default::default(), announces: Default::default(), validity: Default::default(), - request_token: crate::RequestToken::default(), } } @@ -757,15 +746,6 @@ where self } - /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE - /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ - /// way (MoQ request-token). A shared handle, so a replaced token is re-presented on each - /// live subscription as a REQUEST_UPDATE. - pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { - self.request_token = token; - self - } - /// Follow the session's limit, attaching every withheld namespace a new limit /// covers. Each route's own serve task holds itself back when a limit no longer /// covers it. @@ -1332,12 +1312,7 @@ where true => None, false => Some(std::sync::Arc::new(self.acquire_announce()?)), }; - let verdict = self.auth.verify_request( - token, - path.clone(), - crate::auth::RequestKind::PublishNamespace, - slot.clone(), - ); + 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; @@ -2371,35 +2346,15 @@ where // 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. A SUBSCRIBE always carries the token, so - // a token-bearing client does not self-censor on its connection grant: the token - // authorizes what that grant does not cover, and the server's covers-gate is the - // authority. The token stands in for the grant only, never for the limit this side set - // on the peer, which still filters and, narrowing, revokes. Demand with nobody - // subscribing (fetches, or a TRACK_STATUS downstream) learns the track from TRACK_STATUS - // rather than a SUBSCRIBE: a finished track refuses one, and one made only to learn the - // track races the fetches it was for. Neither carries the token, so that demand needs - // the grant. Draft-17 still SUBSCRIBEs: its TRACK_STATUS answer cannot say whether the - // track is timed. - let status = request.subscription().is_none() && ietf::TrackStatusOk::describes_track(self.version); - let token_authorized = !status && self.request_token.peek().is_some(); - let allowed = match token_authorized { - true => self - .auth - .within_limit(crate::auth::Direction::Subscribe, broadcast_path.as_str()), - false => self - .auth - .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()), - }; - if !allowed { + // 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 gate_for = match token_authorized { - true => crate::auth::Gate::limit, - false => crate::auth::Gate::new, - }; - let mut gate = gate_for( + let mut gate = crate::auth::Gate::new( self.auth.clone(), broadcast_path.to_owned(), crate::auth::Direction::Subscribe, @@ -2409,21 +2364,27 @@ where // Group FETCHes for cache misses: standalone, so they outlive each subscription. let mut group_fetches = TaskSet::owned(); - let mut target = match status { - true => match self.track_status(&broadcast_path, &track_name, request).await { - Some(Target::Resume(idle)) => { - let Some(next) = self - .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) - .await - else { - return; - }; - Target::Resume(next) + // Demand with nobody subscribing (fetches, or a TRACK_STATUS downstream) learns the + // track from TRACK_STATUS rather than a SUBSCRIBE: a finished track refuses one, and + // one made only to learn the track races the fetches it was for. Draft-17 still + // SUBSCRIBEs: its TRACK_STATUS answer cannot say whether the track is timed. + let mut target = match request.subscription() { + None if ietf::TrackStatusOk::describes_track(self.version) => { + match self.track_status(&broadcast_path, &track_name, request).await { + Some(Target::Resume(idle)) => { + let Some(next) = self + .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) + .await + else { + return; + }; + Target::Resume(next) + } + Some(target) => target, + None => return, } - Some(target) => target, - None => return, - }, - false => Target::Request(request), + } + _ => Target::Request(request), }; loop { let Some(idle) = self @@ -2519,21 +2480,6 @@ where let aggregate = target.subscription(); let priority = aggregate.as_ref().map(|s| s.priority).unwrap_or(0); let order = aggregate.map(|s| s.order).unwrap_or_default(); - // Re-sent unchanged on a draft-14 request-token renewal, whose fields are all mandatory. - let subscriber_priority = super::priority::to_wire(priority); - // What the SUBSCRIBE carries, which a replacement set before SUBSCRIBE_OK must renew. - let sent_token = self.request_token.peek(); - // The peer holds the request to the token it carries, or to our grant without one, so - // the gate follows what is sent rather than what admission saw. - let gate_for = match sent_token.is_some() { - true => crate::auth::Gate::limit, - false => crate::auth::Gate::new, - }; - *gate = gate_for( - self.auth.clone(), - broadcast_path.to_owned(), - crate::auth::Direction::Subscribe, - ); let written = async { stream.writer.varint(ietf::Subscribe::ID).await?; stream @@ -2542,7 +2488,7 @@ where request_id, track_namespace: broadcast_path.to_owned(), track_name: track_name.into(), - subscriber_priority, + subscriber_priority: super::priority::to_wire(priority), group_order: order.into(), filter: join.filter, fill: join.fill, @@ -2550,7 +2496,7 @@ where properties_wanted: matches!(target, Target::Request(_)), forward: true, range_filters: false, - authorization_token: sent_token.clone().map(crate::ietf::token::RequestToken), + authorization_token: None, }) .await } @@ -2713,12 +2659,6 @@ where priority, largest, } = accepted; - // Where this subscription began: the object after the Largest Object SUBSCRIBE_OK - // named, which every draft before 20 subscribes at. Draft-14's renewal restates it. - let renewal_start = largest.map_or(ietf::Location { group: 0, object: 0 }, |largest| ietf::Location { - group: largest.group, - object: largest.object.saturating_add(1), - }); // A resumed copy opts out of the properties from draft-20, so it keeps the units it // learned. Before that the answer declares them again. let timescale = timescale.or(resumed.as_ref().and_then(|idle| idle.timescale)); @@ -2807,15 +2747,6 @@ where enum End { Idle, Revoked, - /// The client replaced its request token: re-present it on this live subscription. - Renew(Option), - /// The publisher answered the renewal in flight (REQUEST_OK / REQUEST_ERROR), so - /// a replacement coalesced behind it may now be sent. - Answered, - /// The wait for a request ID to carry the pending renewal ended. - Credit(Result), - /// The buffered renewal failed to reach the stream. - Unsent(Error), Done(Result), Fetch(group::Request), } @@ -2826,36 +2757,8 @@ where let demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); - // The client's current request token, and the one last presented on this subscription: - // both start as the token its SUBSCRIBE carried. - let mut last_token = sent_token.clone(); - let mut presented = sent_token; - // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement - // that arrives while one is in flight is coalesced into `last_token` and sent once - // the outstanding one is answered, so the sender never outruns the receiver's - // MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7) whatever its value, without - // reading it. `in_flight` is the token value awaiting an answer, `None` when nothing - // is outstanding. Draft-14 answers no accepted renewal, so it cannot pace on answers - // and re-presents each change once the last has gone; it also advertises no credit - // to exceed. - let throttle = !matches!(self.version, Version::Draft14); - // A renewal goes out in steps polled alongside the loop's terminals, so a revoked grant - // or a departed subscriber never waits behind one, nor on a peer that stopped reading: - // the wait for a request ID (`credit`, for the token in `sending`), then the buffered - // message draining to the stream (`unflushed`). Dropping either step midway tears - // nothing: no ID is taken until the wait is ready, and the writer keeps a message whole. - let mut sending: Option = None; - let mut credit = std::pin::pin!(futures::future::Fuse::terminated()); - let mut unflushed = false; - // The token awaiting an answer, and on drafts 15 and 16 the routing that brings the - // answer back here. - let mut in_flight: Option<(crate::setup::Token, Option)> = None; - // Bumped by `read_publish_done` on each renewal answer; the loop releases the - // coalesced replacement when it advances past `seen_answers`. - let answers = kio::Shared::new(0u64); - let mut seen_answers = 0u64; let idle = { - let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version, &answers)); + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); loop { let end = kio::wait(|waiter| { if !fetch_done @@ -2889,103 +2792,11 @@ where if !subscribed || demand.poll_unused(waiter).is_ready() { return Poll::Ready(End::Idle); } - if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { - return Poll::Ready(End::Renew(token)); - } - if sending.is_some() - && let Poll::Ready(id) = waiter.poll_future(credit.as_mut()) - { - return Poll::Ready(End::Credit(id)); - } - if unflushed && let Poll::Ready(flushed) = stream.writer.poll_flush(&mut waiter.context()) { - unflushed = false; - if let Err(err) = flushed { - return Poll::Ready(End::Unsent(err)); - } - } - // Only wait on an answer while a renewal is actually outstanding. - if throttle - && in_flight.is_some() - && let Poll::Ready(count) = answers.poll(waiter, |count| match **count != seen_answers { - true => Poll::Ready(()), - false => Poll::Pending, - }) { - seen_answers = *count; - return Poll::Ready(End::Answered); - } waiter.poll_future(done.as_mut()).map(End::Done) }) .await; match end { - // The newest token goes out once nothing holds it back, below. A renewal still - // waiting for its request ID takes the newest instead, or is dropped when the - // token was cleared or set back to the one presented: no ID is taken yet, so - // a superseded token never reaches the wire. - End::Renew(token) => { - last_token = token; - if sending.is_some() { - match last_token.is_some() && last_token != presented { - true => sending = last_token.clone(), - false => { - sending = None; - credit.set(futures::future::Fuse::terminated()); - } - } - } - } - // The outstanding renewal was answered, so a replacement coalesced behind it - // may go out. - End::Answered => in_flight = None, - // A replaced token is re-presented as a token-only REQUEST_UPDATE, buffered whole - // and flushed by the wait above; the read future consumes the answer. - End::Credit(Ok(update_id)) => { - let token = sending.take().expect("credit without a pending renewal"); - match self.buffer_request_token_update( - &mut stream.writer, - update_id, - request_id, - subscriber_priority, - renewal_start, - token.clone(), - ) { - Ok(follow_up) => { - presented = Some(token.clone()); - // Hand it to the stream before anything else can take a request ID. - // Drafts 14-16 need IDs to arrive in order, and their adapter never - // parks, so this puts the update on the control stream ahead of any - // later request. A real stream that parks drains in the wait above. - unflushed = - match std::future::poll_fn(|cx| Poll::Ready(stream.writer.poll_flush(cx))).await { - Poll::Ready(Ok(())) => false, - Poll::Ready(Err(err)) => { - tracing::warn!(%err, "failed to re-present the request token"); - let _ = track.clone().abort(err); - break true; - } - Poll::Pending => true, - }; - if throttle { - in_flight = Some((token, follow_up)); - // Only an answer that arrives after this send releases the - // coalesced follow-up, so a stray earlier answer cannot. - seen_answers = *answers.lock(); - } - } - Err(err) => { - tracing::warn!(%err, "failed to re-present the request token"); - let _ = track.clone().abort(err); - break true; - } - } - } - // The token went unpresented, so the subscription ends now rather than run on - // the old grant with nothing left to retry it. - End::Credit(Err(err)) | End::Unsent(err) => { - tracing::warn!(%err, "failed to re-present the request token"); - let _ = track.clone().abort(err); - break true; - } End::Revoked => { tracing::info!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "subscription no longer authorized"); let _ = track.clone().abort(Error::Unauthorized); @@ -3053,42 +2864,15 @@ where break false; } } - - // Start the next renewal once nothing holds it back: no renewal still waiting for - // its request ID and, where renewals are paced, none awaiting an answer. A cleared - // token (`None`) is not a renewal and sends nothing, and a subscription whose - // SUBSCRIBE carried no token stands on the connection grant, with no token to renew. - if sending.is_none() - && !(throttle && in_flight.is_some()) - && presented.is_some() - && last_token.is_some() - && last_token != presented - { - sending = last_token.clone(); - credit.set(futures::FutureExt::fuse(request_credit( - self.control.clone(), - self.runtime.clone(), - ))); - } } }; // Clean up let aborted = self.remove_subscribe(request_id).is_none(); - // A renewal still stuck behind a peer that stopped reading would be cut off mid-message - // by a FIN, so the stream is reset instead. Only a real stream can stick: drafts 14-16 - // write to the control stream's adapter, which never parks. - let stuck = - unflushed && std::future::poll_fn(|cx| Poll::Ready(stream.writer.poll_flush(cx).is_pending())).await; - if !idle { // The publisher already ended the request, so a FIN is all we owe it. - if stuck { - stream.writer.abort(&Error::Cancel); - } else { - stream.writer.finish().ok(); - } + stream.writer.finish().ok(); return None; } // What the copy cached goes stale from here. Marked before the cancel, which waits @@ -3097,15 +2881,7 @@ where if !aborted && !revoked { track.set_idle(); } - match stuck { - // Resetting both directions is the cancellation on the drafts where a stream can stick. - true => { - let Stream { writer, mut reader } = stream; - reader.abort(&Error::Cancel); - writer.abort(&Error::Cancel); - } - false => self.cancel_subscribe(stream, request_id).await, - } + self.cancel_subscribe(stream, request_id).await; // A session abort took the copy too, and a revoked grant ended it. if aborted || revoked { return None; @@ -3221,95 +2997,16 @@ where /// /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN /// without one is a failed request, not a clean end. - /// - /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream - /// (REQUEST_OK / REQUEST_ERROR on draft-15+; draft-14 is silent on an accepted renewal and - /// answers a refused one with SUBSCRIBE_ERROR). Each answer bumps `answers` so the send - /// loop can release the renewal it coalesced behind the one in flight; the read continues - /// regardless, since the subscription ends on its PUBLISH_DONE, which the publisher sends - /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. - async fn read_publish_done( - reader: &mut Reader, - version: Version, - answers: &kio::Shared, - ) -> Result { - loop { - match reader.varint_maybe().await? { - Some(ietf::PublishDone::ID) => { - let msg: ietf::PublishDone = reader.decode().await?; - tracing::debug!(message = ?msg, "received publish done"); - msg.end(version)?; - return Ok(msg.stream_count); - } - Some(ietf::RequestOk::ID) => { - let msg: ietf::RequestOk = reader.decode().await?; - tracing::debug!(message = ?msg, "request token renewal accepted"); - *answers.lock() += 1; - } - Some(ietf::RequestError::ID) => { - // Draft-15+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; - // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the - // renewal; the subscription then ends on the PUBLISH_DONE that follows, not - // on this answer. - match version { - Version::Draft14 => { - let msg: ietf::SubscribeError = reader.decode().await?; - tracing::warn!(message = ?msg, "request token renewal refused"); - } - _ => { - let msg: ietf::RequestError = reader.decode().await?; - tracing::warn!(message = ?msg, "request token renewal refused"); - } - } - *answers.lock() += 1; - } - Some(_) => return Err(Error::UnexpectedMessage), - None => return Err(Error::ProtocolViolation), - } + async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { + match reader.varint_maybe().await? { + Some(ietf::PublishDone::ID) => {} + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), } - } - - /// Buffer a re-presentation of the client's request token on a live subscription as a - /// token-only REQUEST_UPDATE (SUBSCRIBE_UPDATE) carrying `request_id`, so a refreshed - /// credential reaches the publisher before the old grant lapses (MoQ request-token - /// renewal). The caller flushes it. - /// - /// Token-only: draft-15+ omits the range, priority and forward flag, which an update keeps - /// as they are. Draft-14's fields are fixed, so it restates the subscription's own: - /// `start` is where it began (the Largest Object after SUBSCRIBE_OK), which the peer - /// must not see decrease. The answer, if the version sends one, is read on the - /// subscription stream by [`read_publish_done`](Self::read_publish_done); on drafts 15 - /// and 16 the returned guard routes it there until dropped. - fn buffer_request_token_update( - &self, - writer: &mut crate::coding::Writer, - request_id: RequestId, - subscription_id: RequestId, - subscriber_priority: u8, - start: ietf::Location, - token: crate::setup::Token, - ) -> Result, Error> { - // Drafts 15 and 16 answer naming the update itself; draft 14 answers no accepted - // update, and later drafts answer on the subscription's own stream. - let follow_up = matches!(self.version, Version::Draft15 | Version::Draft16) - .then(|| self.control.follow_up(request_id, subscription_id)); - // Draft-14/15/16 name the subscription being updated; draft-17+ identifies it by stream. - let subscription_request_id = match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(subscription_id), - _ => None, - }; - let draft14 = self.version == Version::Draft14; - writer.buffer_message(&ietf::SubscribeUpdate { - request_id, - subscription_request_id, - start_location: start, - end_group: 0, - subscriber_priority: draft14.then_some(subscriber_priority), - forward: draft14.then_some(true), - filter: None, - authorization_token: Some(crate::ietf::token::RequestToken(token)), - })?; - Ok(follow_up) + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version)?; + Ok(msg.stream_count) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -5295,8 +4992,7 @@ mod tests { let mut session = ScriptedSession::eof(responses); let (_, recv) = session.open_bi().await.unwrap(); let mut reader = Reader::new(recv, Version::Draft19); - let result = - Subscriber::::read_publish_done(&mut reader, Version::Draft19, &kio::Shared::new(0)).await; + let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; if clean { assert_eq!(result.unwrap(), 0); } else { @@ -8090,17 +7786,12 @@ mod tests { 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.scoped().unwrap(); + let mut requests = auth.requests().unwrap(); let token = crate::setup::Token { - kind: 300, + kind: crate::setup::Token::OUT_OF_BAND, value: vec![0x00, 0xff], }; - let verdict = auth.verify_request( - token, - path.clone(), - crate::auth::RequestKind::PublishNamespace, - Some(hold.clone()), - ); + 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( @@ -8496,899 +8187,102 @@ mod tests { } } - /// A SUBSCRIBE_OK for the subscribe stream, framed as the peer sends it, with a Largest so - /// the subscription reaches Established. The scripted session parks after it, keeping the - /// subscription live so the steady-state loop runs. - async fn subscribe_ok_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::SubscribeOk::ID).await.unwrap(); - writer - .encode(&ietf::SubscribeOk { - request_id: match version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(1)), - _ => None, - }, - track_alias: 7, - largest: Some(ietf::Location { group: 0, object: 0 }), - properties: Default::default(), - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - } - - /// Replacing the client's request token re-presents it on a live subscription as a token-only - /// SUBSCRIBE_UPDATE (MoQ request-token renewal), on a legacy draft (draft-14 trailing block) - /// and a strict one (draft-18 message parameters). SUBSCRIBE_UPDATE carries the token from - /// draft-14 on, unlike PUBLISH_NAMESPACE_UPDATE (draft-17+ only). - #[moq_net_sim::test] - async fn setting_a_new_request_token_re_presents_it_on_a_live_subscription() { - for version in [Version::Draft14, Version::Draft18] { - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(version).await); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let token = crate::RequestToken::new(Some(typed(&first))); - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ) - .with_request_token(token.clone()); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - // Held for the test so the track never reads as unused (which would cancel it). - let subscription = track.subscribe(None); - 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; - }); - - // The initial token rides the SUBSCRIBE. - let mut initial = false; - for _ in 0..200 { - if occurrences(&log, &first) >= 1 { - initial = true; - break; - } - settle().await; - } - assert!(initial, "{version}: the initial token must ride the SUBSCRIBE"); - - // Replacing it re-presents the new token on the live subscription as a SUBSCRIBE_UPDATE. - token.set(Some(typed(&second))); - let mut renewed = false; - for _ in 0..200 { - if occurrences(&log, &second) >= 1 { - renewed = true; - break; - } - settle().await; - } - assert!( - renewed, - "{version}: a replaced token must be re-presented on the live subscription" - ); - - // The renewal leaves the subscription's range alone: draft-14 restates where it - // began (the object after SUBSCRIBE_OK's Largest Object, {0, 0} here), and draft-15+ - // omits the filter, priority and forward flag so they keep their values. - let draft14 = version == Version::Draft14; - let mut expected = Vec::new(); - for request_id in 0..16 { - 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::SubscribeUpdate::ID).await.unwrap(); - writer - .encode(&ietf::SubscribeUpdate { - request_id: RequestId(request_id), - subscription_request_id: draft14.then_some(RequestId(1)), - start_location: ietf::Location { group: 0, object: 1 }, - end_group: 0, - // The SUBSCRIBE went out at the lowest wire priority; the renewal restates it. - subscriber_priority: draft14.then_some(0xff), - forward: draft14.then_some(true), - filter: None, - authorization_token: Some(crate::ietf::token::RequestToken(typed(&second))), - }) - .await - .unwrap(); - expected.push(log.writes.lock().unwrap().clone()); - } - assert!( - expected.iter().any(|bytes| occurrences(&log, bytes) == 1), - "{version}: the renewal must keep the subscription's range" - ); - - drop(subscription); - drop(track); - drop(consumer); - serving.abort(); - } + /// 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]), + ) } - /// A subscriber presenting `first` as its request token, under a limit on the peer that - /// covers "room", for the renewal tests below. - fn renewal_subscriber( + /// [`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: crate::lite::test_transport::ScriptedSession, - control: Control, - first: &bytes::Bytes, + session: S, ) -> ( - Subscriber, - crate::auth::Handle, - crate::RequestToken, - crate::util::TaskSet, + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + S, ) { - let auth = crate::auth::Handle::new(true); - auth.authorize(&renewal_limit("room")); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); let (tasks, task_set) = crate::util::TaskSet::new(); - let token = crate::RequestToken::new(Some(typed(first))); + 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, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - control, + session.clone(), + origin, + // The announcing peer is the server here, so its update IDs are odd. + Control::new(None, true), None, - peer::PeerSetup::default(), + peer_setup, crate::Hop::new(1).unwrap(), None, version, tasks, Default::default(), ) - .with_auth(auth.clone()) - .with_request_token(token.clone()); - (subscriber, auth, token, task_set) + .with_auth(auth); + + (subscriber, requests, cred, consumer, session) } - /// A limit on the peer that covers `prefix` both ways. - fn renewal_limit(prefix: &str) -> crate::auth::Grant { + /// 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::subtree(prefix).unwrap().into(), - subscribe: crate::Pattern::subtree(prefix).unwrap().into(), - expires: None, + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: secs.map(|s| runtime.now().checked_add(std::time::Duration::from_secs(s)).unwrap()), } } - - /// A request token rides only on SUBSCRIBE, so demand that learns the track from - /// TRACK_STATUS, with nobody subscribing, needs the connection grant: outside it, the - /// request is refused here rather than sent without the token. - #[moq_net_sim::test] - async fn a_request_token_never_authorizes_a_track_status() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let log = session.log.clone(); - let (mut subscriber, auth, _token, _task_set) = - renewal_subscriber(VERSION, session, Control::new(None, false), &first); - // The connection grant covers only "other". - 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, - }, - ); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let mut query = std::pin::pin!(track.query()); - assert!(futures::poll!(query.as_mut()).is_pending()); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); - let mut ended = false; - for _ in 0..50 { - if futures::poll!(run.as_mut()).is_ready() { - ended = true; - break; - } - settle().await; - } - assert!(ended, "fetch-only demand outside the grant waits on the peer"); - assert!( - matches!(query.await, Err(Error::Unauthorized)), - "fetch-only demand outside the grant is refused" - ); - assert!( - control_message_types(&log, VERSION).is_empty(), - "nothing goes out without the token" - ); - } - - /// A narrowed limit ends a subscription whose renewal is stuck writing to a peer that - /// stopped reading, and the stuck stream is reset rather than cut off mid-message by a FIN. - #[moq_net_sim::test] - async fn a_revocation_ends_a_subscription_whose_renewal_is_stuck_writing() { - const VERSION: Version = Version::Draft18; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - let writable = kio::Producer::new(true); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await) - .with_send_gate(writable.consume()); - let log = session.log.clone(); - let (mut subscriber, auth, token, _task_set) = - renewal_subscriber(VERSION, session, Control::new(None, false), &first); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); - - // The peer stops reading, then the token is replaced: the renewal parks writing. - let Ok(mut open) = writable.write() else { - panic!("send gate closed"); - }; - *open = false; - drop(open); - token.set(Some(typed(&second))); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, &second), 0, "the renewal is stuck"); - - auth.authorize(&renewal_limit("other")); - let mut ended = false; - for _ in 0..20 { - if futures::poll!(run.as_mut()).is_ready() { - ended = true; - break; - } - settle().await; - } - assert!(ended, "the revocation waited behind the stuck renewal"); - assert!( - matches!( - track.poll_complete(&kio::Waiter::noop()), - Poll::Ready(Err(Error::Unauthorized)) - ), - "the subscription ends unauthorized" - ); - assert!(!log.resets().is_empty(), "the stuck stream is reset"); - } - - /// A narrowed limit ends a subscription whose renewal waits for request ID credit at once, - /// not when the wait gives up. - #[moq_net_sim::test] - async fn a_revocation_ends_a_subscription_whose_renewal_waits_for_credit() { - const VERSION: Version = Version::Draft14; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log = session.log.clone(); - // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. - let (mut subscriber, auth, token, _task_set) = - renewal_subscriber(VERSION, session, Control::new(Some(RequestId(4)), false), &first); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); - - token.set(Some(typed(&second))); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - - auth.authorize(&renewal_limit("other")); - let mut ended = None; - for _ in 0..20 { - let _ = futures::poll!(run.as_mut()); - if let Poll::Ready(result) = track.poll_complete(&kio::Waiter::noop()) { - ended = result.err(); - break; - } - settle().await; - } - assert!( - matches!(ended, Some(Error::Unauthorized)), - "the revocation waited for the credit wait to give up: {ended:?}" - ); - assert_eq!(occurrences(&log, &second), 0, "the token never went out"); - } - - /// A subscription whose SUBSCRIBE carried no token stands on the connection grant, so a - /// token set later renews nothing on it: the receiver would refuse a token on a request it - /// admitted without one. - #[moq_net_sim::test] - async fn a_token_set_later_never_renews_a_tokenless_subscription() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let request_token = crate::RequestToken::new(None); - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ) - .with_request_token(request_token.clone()); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if !control_message_types(&log, VERSION).is_empty() { - break; - } - settle().await; - } - assert_eq!( - control_message_types(&log, VERSION).first(), - Some(&ietf::Subscribe::ID), - "the SUBSCRIBE goes out" - ); - - request_token.set(Some(typed(&token))); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!( - occurrences(&log, &token), - 0, - "the token renewed a tokenless subscription" - ); - } - - /// A token replaced or cleared while its renewal waits for a request ID never reaches the - /// wire: the renewal carries the newest token, or is dropped once the token is cleared. - #[moq_net_sim::test] - async fn a_superseded_renewal_never_goes_out() { - const VERSION: Version = Version::Draft14; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let stale = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - let newest = bytes::Bytes::from_static(&[0x03, 0x00, b'c', b'c']); - let cleared = bytes::Bytes::from_static(&[0x03, 0x00, b'd', b'd']); - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log = session.log.clone(); - // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. - let (mut subscriber, _auth, token, _task_set) = - renewal_subscriber(VERSION, session, Control::new(Some(RequestId(4)), false), &first); - let control = subscriber.control.clone(); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("room/x"), dynamic, request)); - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); - - // Replaced while waiting for credit: only the newest goes out once credit arrives. - token.set(Some(typed(&stale))); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - token.set(Some(typed(&newest))); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - control.max_request_id(RequestId(6)); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &newest) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &newest), 1, "the newest token goes out"); - assert_eq!(occurrences(&log, &stale), 0, "the superseded token never does"); - - // Cleared while waiting for credit: nothing goes out once credit arrives. - token.set(Some(typed(&cleared))); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - token.set(None); - for _ in 0..20 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - control.max_request_id(RequestId(8)); - for _ in 0..50 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!(occurrences(&log, &cleared), 0, "a cleared token never goes out"); - } - /// A replaced token that cannot be presented, for want of request ID credit, ends the - /// subscription instead of leaving it on the old grant with no retry. - #[moq_net_sim::test] - async fn a_renewal_without_request_id_credit_ends_the_subscription() { - const VERSION: Version = Version::Draft14; - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let token = crate::RequestToken::new(Some(typed(&first))); - // Credit for the SUBSCRIBE and its joining FETCH, none for a renewal. - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(Some(RequestId(4)), false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ) - .with_request_token(token.clone()); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let _subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut run = std::pin::pin!(subscriber.run_subscribe(Path::new("broadcast"), dynamic, request)); - - for _ in 0..200 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &first) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &first), 1, "the initial token rides the SUBSCRIBE"); - - token.set(Some(typed(&second))); - // Through the wait for request ID credit, which gives up after ten seconds. - let mut ended = None; - for _ in 0..30 { - let _ = futures::poll!(run.as_mut()); - if let Poll::Ready(result) = track.poll_complete(&kio::Waiter::noop()) { - ended = result.err(); - break; - } - settle().await; - moq_net_sim::advance(std::time::Duration::from_secs(1)).await; - } - assert!( - matches!(ended, Some(Error::Cancel)), - "a token that cannot be presented ends the subscription: {ended:?}" - ); - assert_eq!(occurrences(&log, &second), 0, "the token never went out"); - } - - /// A token replaced while SUBSCRIBE_OK is outstanding still goes out as a renewal: the - /// subscription tracks the token its SUBSCRIBE carried, not the one current at SUBSCRIBE_OK. - #[moq_net_sim::test] - async fn a_token_replaced_before_subscribe_ok_is_renewed() { - for version in [Version::Draft14, Version::Draft18] { - let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); - let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); - - // SUBSCRIBE_OK is withheld until the token is replaced. - let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); - let peer = session.clone(); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let token = crate::RequestToken::new(Some(typed(&first))); - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - version, - tasks, - Default::default(), - ) - .with_request_token(token.clone()); - - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - // Held for the test so the track never reads as unused (which would cancel it). - let subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let mut serving = std::pin::pin!(subscriber.run_subscribe(Path::new("broadcast"), dynamic, request)); - - let mut sent = false; - for _ in 0..200 { - let _ = futures::poll!(serving.as_mut()); - if occurrences(&log, &first) >= 1 { - sent = true; - break; - } - settle().await; - } - assert!(sent, "{version}: the initial token must ride the SUBSCRIBE"); - - token.set(Some(typed(&second))); - peer.push(&subscribe_ok_bytes(version).await); - let mut renewed = false; - for _ in 0..200 { - let _ = futures::poll!(serving.as_mut()); - if occurrences(&log, &second) >= 1 { - renewed = true; - break; - } - settle().await; - } - assert!( - renewed, - "{version}: a token replaced before SUBSCRIBE_OK must be renewed" - ); - - drop(subscription); - drop(track); - drop(consumer); - } - } - - /// A token set after admission but before the SUBSCRIBE goes out rides it, and the - /// subscription is then held to the token, not the connection grant: narrowing that grant - /// does not cancel what the peer admitted on the token. - #[moq_net_sim::test] - async fn a_token_set_before_the_subscribe_goes_out_governs_it() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - let grant = |subtree: &str| crate::auth::Grant { - publish: crate::Patterns::new(), - subscribe: crate::Pattern::subtree(subtree).unwrap().into(), - expires: None, - }; - - let auth = crate::auth::Handle::new(true); - let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); - auth.granted(0, grant("room")); - let open = kio::Producer::new(false); - let session = crate::lite::test_transport::ScriptedSession::gated_open( - vec![subscribe_ok_bytes(VERSION).await], - open.consume(), - ); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let request_token = crate::RequestToken::new(None); - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ) - .with_auth(auth.clone()) - .with_request_token(request_token.clone()); - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; - }); - - // Admitted by the grant, then parked opening its stream while the token is set. - for _ in 0..20 { - settle().await; - } - assert!(log.writes.lock().unwrap().is_empty(), "the SUBSCRIBE was not held"); - request_token.set(Some(typed(&token))); - let Ok(mut opened) = open.write() else { - panic!("stream gate closed"); - }; - *opened = true; - drop(opened); - for _ in 0..200 { - if occurrences(&log, &token) >= 1 { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, &token), 1, "the SUBSCRIBE carries the token"); - - auth.granted(0, grant("other")); - for _ in 0..100 { - settle().await; - } - assert!( - !serving.is_finished(), - "narrowing the connection grant cancelled a token-admitted subscription" - ); - - drop(subscription); - drop(track); - drop(consumer); - serving.abort(); - } - - /// A token-bearing client does not self-censor on its connection grant: the - /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the - /// token; a token-less client with the same grant rejects it locally, as before. - #[moq_net_sim::test] - async fn a_token_bearing_client_subscribes_outside_its_connection_grant() { - const VERSION: Version = Version::Draft18; - let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - - // A connection grant of "other", which does not cover "room/x". Held for the run. - fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { - 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 { - publish: crate::Patterns::new(), - subscribe: crate::Pattern::subtree("other").unwrap().into(), - expires: None, - }, - ); - (auth, cred) - } - assert!( - !seed_auth().0.allows(crate::auth::Direction::Subscribe, "room/x"), - "the connection grant must not cover the subscribed path" - ); - - // With a token, the SUBSCRIBE for room/x reaches the wire carrying the token. - let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log = session.log.clone(); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let (auth, _cred) = seed_auth(); - let mut subscriber = Subscriber::new( - crate::time::Clock::sim(), - session, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks, - Default::default(), - ) - .with_auth(auth) - .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - let producer = crate::broadcast::Info::default().produce(); - let mut dynamic = producer.dynamic(); - let consumer = producer.consume(); - let track = consumer.track("video").unwrap(); - let subscription = track.subscribe(None); - let request = dynamic.requested_track().await.expect("no track requested"); - let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; - }); - let mut sent = false; - for _ in 0..200 { - if occurrences(&log, &token) >= 1 { - sent = true; - break; - } - settle().await; - } - assert!( - sent, - "a token-bearing client must subscribe outside its connection grant" - ); - drop(subscription); - drop(track); - drop(consumer); - serving.abort(); - - // Without a token, the same grant rejects the subscribe locally: no SUBSCRIBE is sent. - let session2 = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); - let log2 = session2.log.clone(); - let (tasks2, _task_set2) = crate::util::TaskSet::new(); - let (auth2, _cred2) = seed_auth(); - let mut subscriber2 = Subscriber::new( - crate::time::Clock::sim(), - session2, - crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), - Control::new(None, false), - None, - peer::PeerSetup::default(), - crate::Hop::new(1).unwrap(), - None, - VERSION, - tasks2, - Default::default(), - ) - .with_auth(auth2); - let producer2 = crate::broadcast::Info::default().produce(); - let mut dynamic2 = producer2.dynamic(); - let consumer2 = producer2.consume(); - let track2 = consumer2.track("video").unwrap(); - let subscription2 = track2.subscribe(None); - let request2 = dynamic2.requested_track().await.expect("no track requested"); - let serving2 = moq_net_sim::spawn(async move { - subscriber2.run_subscribe(Path::new("room/x"), dynamic2, request2).await; - }); - for _ in 0..80 { - settle().await; - } - assert!( - !control_message_types(&log2, VERSION).contains(&ietf::Subscribe::ID), - "a token-less client rejects a subscribe outside its grant locally" - ); - drop(subscription2); - drop(track2); - drop(consumer2); - serving2.abort(); - } - - /// 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.scoped().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()), - } - } - - /// 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: 300, - value: vec![0x00, 0xff], - }) + /// 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> { @@ -10511,7 +9405,7 @@ mod tests { limit: Option, ) -> ( Subscriber, - crate::auth::Requests, + crate::auth::Requests, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -10528,7 +9422,7 @@ mod tests { auth: crate::auth::Handle, ) -> ( Subscriber, - crate::auth::Requests, + crate::auth::Requests, origin::Consumer, crate::lite::test_transport::ScriptedSession, ) { @@ -10540,7 +9434,7 @@ mod tests { let peer_setup = peer::PeerSetup::default(); peer_setup.set(peer::Peer::default()); - let requests = auth.scoped().unwrap(); + let requests = auth.requests().unwrap(); let subscriber = Subscriber::new( crate::time::Clock::sim(), @@ -10936,15 +9830,6 @@ mod tests { .await } } - - /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. - fn typed(raw: &[u8]) -> crate::setup::Token { - assert_eq!(raw[0], 0x03, "USE_VALUE"); - crate::setup::Token { - kind: raw[1].into(), - value: raw[2..].to_vec(), - } - } } /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 8c1f71814b..63ff8b119c 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -119,8 +119,6 @@ pub mod stats; pub mod time; pub mod transport; -#[cfg(feature = "async")] -pub(crate) use auth::RequestToken; #[cfg(feature = "async")] pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, varint}; diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index 4d0ac13831..75c8784da0 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -661,8 +661,6 @@ pub struct ScriptedSession { incoming_bidis: Arc>>>, /// Set by [`Self::with_unacked_fin`]. unacked_fin: bool, - /// Set by [`Self::with_send_gate`]. - send_gate: Option>, } impl ScriptedSession { @@ -683,17 +681,9 @@ impl ScriptedSession { incoming_unis: Arc::new(Mutex::new(std::collections::VecDeque::new())), incoming_bidis: Arc::new(Mutex::new(std::collections::VecDeque::new())), unacked_fin: false, - send_gate: None, } } - /// Park writes on every opened stream while `gate` reads false, like a peer that stopped - /// reading and left no flow-control credit. - pub fn with_send_gate(mut self, gate: kio::Consumer) -> Self { - self.send_gate = Some(gate); - self - } - /// 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; @@ -824,10 +814,7 @@ 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 = match &self.send_gate { - Some(gate) => SinkSend::gated(self.log.clone(), gate.clone()), - None => SinkSend::new(self.log.clone()), - }; + let mut send = SinkSend::new(self.log.clone()); send.unacked_fin = self.unacked_fin; Poll::Ready(Ok(( send, diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index cb384e5954..371e347a16 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -297,8 +297,6 @@ impl Server { } PausedHandshake::Boxed(_) => false, }); - // Refuse a request token set before `ok()` too. - auth.lite(); Ok(Handshake { path, role, @@ -451,19 +449,14 @@ impl Server { Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; - // Lite 01/02 and moq-transport 14-16 carry no AUTH. - let auth = crate::auth::Handle::new(false); - // Refuse a request token set before `ok()` too. - if matches!(version, Version::Lite(_)) { - auth.lite(); - } Ok(Handshake { path, role: None, origin: None, token, assigned_hop: crate::Hop::random(), - auth, + // Lite 01/02 and moq-transport 14-16 carry no AUTH. + auth: crate::auth::Handle::new(false), inner: Some(RequestInner { server: self.clone(), runtime, @@ -1224,7 +1217,7 @@ mod tests { params } - /// A verifier taken with `Request::auth().scoped()` BEFORE `ok()` must govern the + /// 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. @@ -1251,7 +1244,7 @@ mod tests { .unwrap_or_else(|e| panic!("accept at {version:?}: {e}")); // Install the verifier before ok(), as a QUIC server must. - let mut requests = request.auth().scoped().expect("scoped() pre-ok"); + 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 @@ -1259,20 +1252,13 @@ mod tests { // the Unsupported default and the consumer would never see this request. let verdict = net_session.auth().verify_request( crate::setup::Token { - kind: 1, + kind: crate::setup::Token::OUT_OF_BAND, value: b"jwt".to_vec(), }, - crate::PathOwned::from("room/alice".to_string()), - crate::auth::RequestKind::PublishNamespace, None, ); let received = requests.next().await.expect("the pre-ok consumer receives the request"); - assert_eq!(received.path().as_str(), "room/alice", "{version:?}"); - assert_eq!( - received.kind(), - crate::auth::RequestKind::PublishNamespace, - "{version:?}" - ); + 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:?}"); } @@ -1379,21 +1365,6 @@ mod tests { assert_eq!(request.role(), None, "a client that omits the role is bidirectional"); } - /// moq-lite carries no request token, so setting one before `ok()` already refuses. - #[moq_net_sim::test] - async fn a_lite_handshake_refuses_a_request_token() { - let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); - let request = Server::new().accept_request(moq_net_sim::now(), session).await.unwrap(); - let token = crate::setup::Token { - kind: 0, - value: b"jwt".to_vec(), - }; - assert!(matches!( - request.auth().set_request_token(Some(token)), - Err(Error::Unsupported) - )); - } - #[moq_net_sim::test] async fn accept_request_lite05_without_path_is_empty() { let session = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index a152bd44ba..2c7e97dd01 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -306,9 +306,6 @@ impl Session { S: crate::transport::poll::Session, { let Handles { goaway, auth, estimate } = handles; - if version.is_lite() { - auth.lite(); - } let sample = snapshot(&session); if let Some(estimate) = &estimate && let Ok(mut current) = estimate.write() diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 45fbd9acf8..ee2f9e96a9 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -33,11 +33,6 @@ const LITE_06: &str = "moq-lite-06"; const AUTH_STREAM: u8 = 0x7; /// The first draft that negotiates MoQ Auth, and the newest. const MOQT_17: &str = "moq-transport-17"; -/// A draft-18 session, the shape a standard request-token peer uses. -const MOQT_15: &str = "moq-transport-15"; -const MOQT_16: &str = "moq-transport-16"; -const MOQT_18: &str = "moq-transport-18"; -const MOQT_19: &str = "moq-transport-19"; const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. @@ -203,14 +198,6 @@ async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { } } -/// A request token of kind 0 carrying `value`. -fn request_token(value: &[u8]) -> moq_net::setup::Token { - moq_net::setup::Token { - kind: 0, - value: value.to_vec(), - } -} - #[derive(Default)] struct Options { client_publish: Option, @@ -219,14 +206,6 @@ struct Options { server_subscribe: Option, /// Take the server's AUTH requests before its driver runs. server_requests: bool, - /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. - client_request_token: Option, - /// The client does not offer the MoQ Auth extension (`Extensions::auth` off). - client_decline_auth: bool, - /// The server does not offer the MoQ Solicit extension (`Extensions::solicit` off). - server_decline_solicit: bool, - /// The server does not offer the MoQ Auth extension (`Extensions::auth` off). - server_decline_auth: bool, version: Option<&'static str>, } @@ -235,10 +214,7 @@ struct Pair { server: Session, client_transport: MockSession, server_transport: MockSession, - /// The server's session tokens, when it took them. requests: Option, - /// The server's request tokens, when it took them. - scoped: Option>, /// Aborting it drops the server's driver without letting it finish. server_driver: moq_net_sim::JoinHandle, } @@ -254,19 +230,8 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } - if opts.client_decline_auth { - let mut extensions = moq_net::setup::Extensions::default(); - extensions.auth = false; - client = client.with_extensions(extensions); - } let mut server = Server::new().with_versions(version.into()); - if opts.server_decline_solicit || opts.server_decline_auth { - let mut extensions = moq_net::setup::Extensions::default(); - extensions.solicit = !opts.server_decline_solicit; - extensions.auth = !opts.server_decline_auth; - server = server.with_extensions(extensions); - } if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } @@ -276,16 +241,8 @@ async fn connect(opts: Options) -> Pair { let observe = client_transport.clone(); let observe_server = server_transport.clone(); - let client_token = opts.client_request_token; let client_fut = async { let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); - // Set before the driver runs, so the first request already carries it. - if let Some(token) = client_token { - session - .auth() - .set_request_token(Some(token)) - .expect("a moq-transport session carries a request token"); - } spawn(driver); session }; @@ -298,15 +255,13 @@ async fn connect(opts: Options) -> Pair { let requests = opts .server_requests .then(|| handshake.auth().requests().expect("requests available before ok()")); - // moq-lite carries no request token, so it has none to verify. - let scoped = opts.server_requests.then(|| handshake.auth().scoped().ok()).flatten(); let (session, mut driver) = handshake.ok().await.expect("server accept"); let driver = moq_net_sim::spawn(moq_net_sim::drive(move |now, waiter| { moq_net::time::Driver::poll(&mut driver, now, waiter) })); - (session, requests, scoped, driver) + (session, requests, driver) }; - let (client, (server, requests, scoped, server_driver)) = futures::join!(client_fut, server_fut); + let (client, (server, requests, server_driver)) = futures::join!(client_fut, server_fut); Pair { client, @@ -314,101 +269,26 @@ async fn connect(opts: Options) -> Pair { client_transport: observe, server_transport: observe_server, requests, - scoped, server_driver, } } -impl Pair { - /// The server's token queues, which it took before `ok()`: request tokens only where the - /// version carries them. - fn verifiers(&mut self) -> (auth::Requests, Option>) { - let requests = self.requests.take().expect("server took its requests pre-ok"); - (requests, self.scoped.take()) - } -} - -/// A grant [`serve`] issued: to a session token, or to a request token. -enum Answered { - Session(auth::Issued), - Request(auth::Enforced), -} - -impl Answered { - fn update(&self, grant: Grant) { - match self { - Self::Session(issued) => issued.update(grant), - Self::Request(enforced) => enforced.update(grant), - } - } - - fn revoke(self, code: SessionError, reason: &str) { - match self { - Self::Session(issued) => issued.revoke(code, reason), - Self::Request(enforced) => enforced.revoke(code, reason), - } - } - - async fn closed(&self) -> Error { - match self { - Self::Session(issued) => issued.closed().await, - Self::Request(enforced) => enforced.closed().await, - } - } -} - /// Answer the peer's tokens from a table, holding every grant (and any request the /// table leaves unanswered) for as long as the returned task lives. fn serve( - (mut requests, mut scoped): (auth::Requests, Option>), + mut requests: auth::Requests, answer: impl Fn(&[u8]) -> Option + 'static, -) -> futures::channel::mpsc::UnboundedReceiver<(Vec, Answered)> { - enum Next { - Session(auth::Request), - Request(auth::Scoped), - } +) -> futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)> { let (tx, rx) = futures::channel::mpsc::unbounded(); moq_net_sim::spawn(async move { let mut unanswered = Vec::new(); - // A session without AUTH closes its session queue at once; request tokens still arrive. - let (mut sessions, mut requested) = (true, scoped.is_some()); - while sessions || requested { - let next = kio::wait(|waiter| { - if sessions && let std::task::Poll::Ready(next) = requests.poll_next(waiter) { - sessions = next.is_some(); - return std::task::Poll::Ready(next.map(Next::Session)); + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + match answer(&token) { + Some(grant) => { + let _ = tx.unbounded_send((token, request.accept(grant))); } - if requested - && let Some(scoped) = scoped.as_mut() - && let std::task::Poll::Ready(next) = scoped.poll_next(waiter) - { - requested = next.is_some(); - return std::task::Poll::Ready(next.map(Next::Request)); - } - std::task::Poll::Pending - }) - .await; - let (token, answered) = match next { - None => continue, - Some(Next::Session(request)) => { - let token = request.token().to_vec(); - let answered = answer(&token).map(|grant| Answered::Session(request.accept(grant))); - (token, answered) - } - Some(Next::Request(request)) => { - let token = request.token().value.clone(); - let answered = match answer(&token) { - Some(grant) => Some(Answered::Request(request.accept(grant))), - None => { - unanswered.push(request); - None - } - }; - (token, answered) - } - }; - if let Some(answered) = answered { - let _ = tx.unbounded_send((token, answered)); + None => unanswered.push(request), } } }); @@ -498,545 +378,6 @@ async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { .expect("timed out"); } -/// A request token on a PUBLISH_NAMESPACE authorizes the announce on a session that never -/// negotiated the MoQ Auth extension (draft-14), the standard moq-transport peer shape, when the -/// token reaches the acceptor THROUGH THE DRIVER rather than an inline `verify_request`. -/// -/// This exercises `ietf::start`'s legacy (draft 14-16) branch. That branch built its -/// Subscriber without `.with_auth(auth)`, so the driver's subscriber consulted a fresh -/// default `Handle` instead of the session handle the `requests()` acceptor was installed on: -/// `verify_request` found no `App` acceptor and refused the announce `NOT_SUPPORTED`, and the -/// announce never reached the server origin. The modern (17+) branch already wired -/// `.with_auth`, so only the legacy / no-extension path was affected, and the unit tests -/// missed it by constructing the Subscriber with `.with_auth` by hand. -#[moq_net_sim::test] -async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { - within(async { - let publisher = produce_origin(2); - let relay = produce_origin(1); - let mut pair = connect(Options { - version: Some("moq-transport-14"), - client_publish: Some(publisher.clone()), - // A request token the acceptor answers unconditionally below. - client_request_token: Some(request_token(b"ok")), - server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), - server_requests: true, - ..Default::default() - }) - .await; - - // The server answers the request token from its acceptor with a grant covering the - // announced path. Held for the test by the returned receiver. - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); - - // The client announces under the token. On the legacy path there is no session grant, - // so the token is the only authorization for the announce. - let bc = publisher.create_broadcast("room/alice").unwrap(); - bc.announce(Default::default()).unwrap(); - - // Mechanism: the token reached the acceptor over the driver (not admitted by a - // permissive default, and not refused NOT_SUPPORTED by a disconnected handle). - let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); - - // End to end: the verified announce reached the server's subscribe origin. - wait_announced(&relay.consume(), "room/alice", true).await; - }) - .await - .expect("timed out"); -} - -/// Draft-16 renews an announce's token with a REQUEST_UPDATE naming the PUBLISH_NAMESPACE on -/// the control stream: replacing the client's token reaches the server's acceptor as a renewal, -/// whose answer finds its way back without disturbing the announce. -#[moq_net_sim::test] -async fn a_request_token_renews_a_legacy_announce_through_the_driver() { - within(async { - let publisher = produce_origin(2); - let relay = produce_origin(1); - let mut pair = connect(Options { - version: Some(MOQT_16), - client_publish: Some(publisher.clone()), - client_request_token: Some(request_token(b"aa")), - server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), - server_requests: true, - // Unsolicited, so the announce is a PUBLISH_NAMESPACE carrying the token. - server_decline_solicit: true, - ..Default::default() - }) - .await; - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &[]))); - - let bc = publisher.create_broadcast("room/alice").unwrap(); - bc.announce(Default::default()).unwrap(); - let (token, _first) = answered.recv().await.expect("the token reached the acceptor"); - assert_eq!(token, b"aa"); - wait_announced(&relay.consume(), "room/alice", true).await; - - pair.client - .auth() - .set_request_token(Some(request_token(b"bb"))) - .unwrap(); - let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); - assert_eq!(token, b"bb", "the replaced token rides the REQUEST_UPDATE"); - - moq_net_sim::sleep(Duration::from_millis(100)).await; - wait_announced(&relay.consume(), "room/alice", true).await; - assert_eq!( - pair.client_transport.close_reason(), - None, - "renewal never touches the session" - ); - assert_eq!(pair.server_transport.close_reason(), None); - }) - .await - .expect("timed out"); -} - -/// A client may decline the MoQ Auth extension, connecting at draft-18 as a peer that -/// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so -/// the server sees `declared.auth == false` and the session carries no connection grant -/// (`None` union). A request-borne `AUTHORIZATION TOKEN` is then the authorizing artifact -/// and reaches the server's acceptor. A token-less request on such a session is admitted by -/// the permissive default of the ungranted session. -/// -/// The token is exercised on a SUBSCRIBE, which is always a request and carries the token -/// on every draft. A request token on a PUBLISH_NAMESPACE does NOT reach a moq-net peer at -/// draft-16+ regardless of this option: moq-net declares MoQ Solicit unconditionally, so -/// the announce answers the peer's SUBSCRIBE_NAMESPACE inline via `ietf::Namespace`, which -/// carries no token, and the token-bearing unsolicited PUBLISH_NAMESPACE loop is disabled. -#[moq_net_sim::test] -async fn a_client_may_decline_the_auth_extension() { - within(async { - let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); - let server_origin = produce_origin(1); - let down = server_origin.create_broadcast("room/alice").unwrap(); - let down_track = down.create_track("video", timed()).unwrap(); - down.announce(Default::default()).unwrap(); - - let received = produce_origin(3); - let mut pair = connect(Options { - version: Some(MOQT_18), - client_subscribe: Some(received.clone()), - client_request_token: Some(request_token(b"ok")), - client_decline_auth: true, - server_publish: Some(server_origin.clone()), - server_requests: true, - ..Default::default() - }) - .await; - - // declared.auth == false: the declining client speaks no AUTH, so it holds no - // session grant and cannot present a session token (unlike a normal draft-18 peer). - assert_eq!(pair.client.auth().grant().peek(), None); - assert!(matches!(pair.client.auth().add("x").await, Err(Error::Unsupported))); - - // The client's token-bearing SUBSCRIBE reaches the acceptor: on the `None`-union - // session the covers-gate does not short-circuit, so the token is verified rather - // than admitted by a covering connection grant. - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); - - let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); - let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); - let mut group = down_track.append_group().unwrap(); - group.write_frame(ts(0), b"down".as_ref()).unwrap(); - - let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); - sub.recv_group().await.unwrap().unwrap(); - - // Token-less: a declining client with no token is admitted by the permissive default. - let bare_publisher = produce_origin(4); - let bare_relay = produce_origin(5); - let bare = connect(Options { - version: Some(MOQT_18), - client_publish: Some(bare_publisher.clone()), - client_decline_auth: true, - server_subscribe: Some(bare_relay.clone()), - ..Default::default() - }) - .await; - let bc = bare_publisher.create_broadcast("room/bob").unwrap(); - bc.announce(Default::default()).unwrap(); - wait_announced(&bare_relay.consume(), "room/bob", true).await; - assert_eq!(bare.client_transport.close_reason(), None); - }) - .await - .expect("timed out"); -} - -/// moq-lite requests carry no token, so a moq-lite session refuses one rather than drop it, -/// and a moq-transport session takes it. -#[moq_net_sim::test] -async fn a_moq_lite_session_refuses_a_request_token() { - within(async { - let lite = connect(Options::default()).await; - let refused = lite.client.auth().set_request_token(Some(request_token(b"ok"))); - assert!(matches!(refused, Err(Error::Unsupported)), "{refused:?}"); - - let ietf = connect(Options { - version: Some(MOQT_18), - ..Default::default() - }) - .await; - ietf.client - .auth() - .set_request_token(Some(request_token(b"ok"))) - .expect("a moq-transport session takes a request token"); - }) - .await - .expect("timed out"); -} - -/// Draft-15/16 answer an update naming the update's own Request ID, which has no stream of -/// its own on the control stream. The answer must still reach the subscription it renews: -/// the client keeps one renewal in flight, so a second one goes out only once the first is -/// answered there. -#[moq_net_sim::test] -async fn a_legacy_renewal_answer_reaches_its_subscription() { - for version in [MOQT_15, MOQT_16] { - within(async { - let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); - let server_origin = produce_origin(1); - let down = server_origin.create_broadcast("room/alice").unwrap(); - let down_track = down.create_track("video", timed()).unwrap(); - down.announce(Default::default()).unwrap(); - - let received = produce_origin(3); - let mut pair = connect(Options { - version: Some(version), - client_subscribe: Some(received.clone()), - client_request_token: Some(request_token(b"aa")), - server_publish: Some(server_origin.clone()), - server_requests: true, - ..Default::default() - }) - .await; - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&[], &["room/alice"]))); - - let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); - let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); - let mut group = down_track.append_group().unwrap(); - group.write_frame(ts(0), b"one".as_ref()).unwrap(); - group.finish().unwrap(); - let (token, _first) = answered.next().await.expect("the first token reached the acceptor"); - assert_eq!(token, b"aa", "{version}"); - sub.recv_group().await.unwrap().unwrap(); - - pair.client - .auth() - .set_request_token(Some(request_token(b"bb"))) - .unwrap(); - let (token, _second) = answered.next().await.expect("the first renewal reached the acceptor"); - assert_eq!(token, b"bb", "{version}"); - - // Sent only once the first renewal's answer came back to the subscription. - moq_net_sim::sleep(Duration::from_millis(100)).await; - pair.client - .auth() - .set_request_token(Some(request_token(b"cc"))) - .unwrap(); - let (token, _third) = answered.next().await.expect("the second renewal reached the acceptor"); - assert_eq!(token, b"cc", "{version}"); - }) - .await - .unwrap_or_else(|_| panic!("{version}: timed out")); - } -} - -/// A request token renews over the wire: replacing it on the client's `Session::auth()` -/// re-presents it on the live SUBSCRIBE as a REQUEST_UPDATE, the server's driver routes it to -/// the acceptor, and the new grant keeps the subscription alive past the old one's expiry. -/// Runs through both drivers at draft-18, in the shape a base moq-transport peer produces -/// (no MoQ Auth, so the token is what authorizes). -#[moq_net_sim::test] -async fn a_request_token_renews_a_subscription_through_the_driver() { - within(async { - let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); - let server_origin = produce_origin(1); - let down = server_origin.create_broadcast("room/alice").unwrap(); - let down_track = down.create_track("video", timed()).unwrap(); - down.announce(Default::default()).unwrap(); - - let first = request_token(b"aa"); - let second = request_token(b"bb"); - let received = produce_origin(3); - let mut pair = connect(Options { - version: Some(MOQT_18), - client_subscribe: Some(received.clone()), - client_request_token: Some(first.clone()), - client_decline_auth: true, - server_publish: Some(server_origin.clone()), - server_requests: true, - ..Default::default() - }) - .await; - - // The first token lapses in a second; the renewal never does. - let expires = Some(now() + Duration::from_secs(1)); - // The acceptor sees the token's value. - let renewal = second.value.clone(); - let mut answered = serve(pair.verifiers(), move |token| { - let mut granted = grant(&[], &["room/alice"]); - if token != renewal.as_slice() { - granted.expires = expires; - } - Some(granted) - }); - - let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); - let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); - let mut group = down_track.append_group().unwrap(); - group.write_frame(ts(0), b"one".as_ref()).unwrap(); - group.finish().unwrap(); - let (token, _first_issued) = answered.recv().await.expect("the first token reached the acceptor"); - assert_eq!(token, first.value); - sub.recv_group().await.unwrap().unwrap(); - - pair.client.auth().set_request_token(Some(second.clone())).unwrap(); - let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); - assert_eq!(token, second.value, "the replaced token rides the REQUEST_UPDATE"); - - // Past the first grant's expiry the subscription still delivers. - moq_net_sim::sleep(Duration::from_millis(1500)).await; - let mut group = down_track.append_group().unwrap(); - group.write_frame(ts(2000), b"two".as_ref()).unwrap(); - group.finish().unwrap(); - sub.recv_group() - .await - .unwrap() - .expect("the renewed subscription is still live"); - assert_eq!( - pair.client_transport.close_reason(), - None, - "renewal never touches the session" - ); - // The same subscription carried on: a lapse would have ended it, and the client's - // re-subscribe would have reached the acceptor as another request. - assert!( - answered.try_recv().is_err(), - "the original subscription was renewed, not replaced" - ); - }) - .await - .expect("timed out"); -} - -/// The sender honors the receiver's MAX_REQUEST_UPDATES credit: it keeps at most one -/// renewal in flight per request and coalesces replacements that arrive while one is -/// unanswered, so a burst of token changes never outruns the credit (draft-19 section -/// 10.3.1.7). -/// -/// Two drivers over the in-process transport at draft-19, where the serving side advertises -/// and enforces a 16-update credit with a session close ([`SessionError::TooManyRequestUpdates`]). -/// The acceptor holds the first renewal's verifier, the client replaces its token well past -/// the credit, and the test asserts the connection stays up, only the held renewal reaches the -/// acceptor, and resolving it releases exactly the newest token, not any coalesced between. -/// -/// A fire-and-forget sender would put every renewal outstanding behind the held verifier and -/// the serving side would close the session, losing every request on it. -/// -/// [`SessionError::TooManyRequestUpdates`]: moq_net::SessionError::TooManyRequestUpdates -#[moq_net_sim::test] -async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { - within(async { - let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); - let prefs = || moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(10)); - let server_origin = produce_origin(1); - let down = server_origin.create_broadcast("room/alice").unwrap(); - let down_track = down.create_track("video", timed()).unwrap(); - down.announce(Default::default()).unwrap(); - - // A distinct value per credential. - let token = |n: u8| request_token(&[b't', n]); - let initial = token(0); - let received = produce_origin(3); - let mut pair = connect(Options { - version: Some(MOQT_19), - client_subscribe: Some(received.clone()), - client_request_token: Some(initial.clone()), - client_decline_auth: true, - server_publish: Some(server_origin.clone()), - server_requests: true, - ..Default::default() - }) - .await; - - // The acceptor runs concurrently with the subscribe (the initial token rides the - // SUBSCRIBE, so nothing reaches it until we subscribe). It reports every token it - // verifies and holds the first renewal's verifier until released, the way a relay with - // a slow authorizer would. - let mut requests = pair.scoped.take().expect("server took its request tokens pre-ok"); - let (seen_tx, mut seen) = futures::channel::mpsc::unbounded::>(); - let (release, release_waiter) = futures::channel::oneshot::channel::<()>(); - let acceptor = moq_net_sim::spawn(async move { - let mut issued = Vec::new(); - let mut count = 0u64; - let mut release_waiter = Some(release_waiter); - while let Some(request) = requests.next().await { - let token = request.token().value.clone(); - count += 1; - seen_tx.unbounded_send(token).ok(); - // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, - // held until the test releases it. - if count == 2 - && let Some(release) = release_waiter.take() - { - let _ = release.await; - } - issued.push(request.accept(grant(&[], &["room/alice"]))); - } - }); - - // Establish and deliver one group so the subscription is live. The initial token rides - // the SUBSCRIBE, which the acceptor (above) verifies concurrently. - let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); - let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); - let mut group = down_track.append_group().unwrap(); - group.write_frame(ts(0), b"one".as_ref()).unwrap(); - group.finish().unwrap(); - sub.recv_group().await.unwrap().unwrap(); - assert_eq!( - seen.next().await.expect("initial token"), - initial.value, - "the SUBSCRIBE carries the token" - ); - - // Replace the token once and let that one renewal reach the held acceptor, so the held - // renewal is deterministic regardless of scheduling. - pair.client.auth().set_request_token(Some(token(1))).unwrap(); - assert_eq!( - seen.next().await.expect("first renewal"), - token(1).value, - "the first replacement goes out immediately" - ); - - // With that renewal held unanswered, replace the token many more times, spaced so the - // driver observes each: the scenario the credit guards. Far past any plausible credit, - // so the test keeps guarding if MAX_REQUEST_UPDATES grows. A fire-and-forget sender - // would put all of these outstanding behind the held verifier and the serving side - // would close the session with TOO_MANY_REQUEST_UPDATES, losing every request on it; - // the one-in-flight rule coalesces them behind the held one instead. - for n in 2..=64u8 { - pair.client.auth().set_request_token(Some(token(n))).unwrap(); - moq_net_sim::sleep(Duration::from_millis(5)).await; - } - - assert_eq!( - pair.client_transport.close_reason(), - None, - "the burst never tripped the receiver's credit" - ); - assert_eq!( - pair.server_transport.close_reason(), - None, - "the server never closed the session" - ); - - // Resolve the held verifier. The sender sends the coalesced renewal carrying the newest - // token, not any of the ones replaced between. - release.send(()).unwrap(); - assert_eq!( - seen.next().await.expect("coalesced renewal"), - token(64).value, - "the newest token wins; the rest are coalesced away" - ); - - // Only the newest coalesced renewal followed the held one: the burst collapsed to one, - // not a backlog queued behind it. - assert!( - moq_net_sim::timeout(Duration::from_millis(100), seen.next()) - .await - .is_err(), - "only the newest coalesced renewal followed, not a backlog" - ); - assert_eq!( - pair.client_transport.close_reason(), - None, - "the session stayed up throughout" - ); - acceptor.abort(); - }) - .await - .expect("timed out"); -} - -/// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited -/// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our -/// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an -/// `AUTHORIZATION TOKEN`; the inline `Namespace` entry has no parameter slot for one. So a -/// request-borne token on an announce reaches the acceptor exactly when the server does not -/// solicit, which is the shape a standard moq-transport peer (an encoder or CDN) always sends. -/// -/// This runs at draft-18, with the client also declining -/// the AUTH extension, so the session's union is `None`, the covers-gate does not short-circuit -/// on a connection grant, and the request token is the authorizing artifact. The -/// control half shows the default: with Solicit declared the client answers inline, no token -/// reaches the acceptor, and the announce is admitted by the permissive default of the -/// ungranted session. -#[moq_net_sim::test] -async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce() { - within(async { - let request_token = request_token(b"ok"); - - // Server declines Solicit: the client sends an unsolicited PUBLISH_NAMESPACE carrying - // the token, which reaches the acceptor as a PublishNamespace request. - let publisher = produce_origin(1); - let relay = produce_origin(2); - let mut pair = connect(Options { - version: Some(MOQT_18), - client_publish: Some(publisher.clone()), - client_request_token: Some(request_token.clone()), - client_decline_auth: true, - server_subscribe: Some(relay.clone()), - server_decline_solicit: true, - server_requests: true, - ..Default::default() - }) - .await; - let bc = publisher.create_broadcast("room/alice").unwrap(); - bc.announce(Default::default()).unwrap(); - - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/alice"], &["room/alice"]))); - // The announce is admitted once the token is granted. - wait_announced(&relay.consume(), "room/alice", true).await; - let (tok, _issued) = answered - .recv() - .await - .expect("the unsolicited PUBLISH_NAMESPACE carried the token to the acceptor"); - assert_eq!(tok, b"ok", "the acceptor saw the request token's decoded value"); - - // Control: with Solicit declared (the default) the same client answers our - // SUBSCRIBE_NAMESPACE inline with a Namespace, which carries no token, so nothing - // reaches the acceptor. The announce is still admitted by the permissive default. - let publisher = produce_origin(3); - let relay = produce_origin(4); - let mut pair = connect(Options { - version: Some(MOQT_18), - client_publish: Some(publisher.clone()), - client_request_token: Some(request_token.clone()), - client_decline_auth: true, - server_subscribe: Some(relay.clone()), - // server_decline_solicit defaults false: the server declares Solicit. - server_requests: true, - ..Default::default() - }) - .await; - let bc = publisher.create_broadcast("room/carol").unwrap(); - bc.announce(Default::default()).unwrap(); - - let mut answered = serve(pair.verifiers(), |_token| Some(grant(&["room/carol"], &[]))); - wait_announced(&relay.consume(), "room/carol", true).await; - assert!( - answered.try_recv().is_err(), - "a solicited (inline) announce carries no token, so the acceptor is never consulted" - ); - }) - .await - .expect("timed out"); -} - /// A broadcast published before the grant arrives is checked at admission too. async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { @@ -1078,7 +419,9 @@ async fn an_unanswered_token_does_not_suspend_the_check(version: &'static str) { ..Default::default() }) .await; - let _issued = serve(pair.verifiers(), |token| token.is_empty().then(|| grant(&["baz"], &[]))); + let _issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["baz"], &[])) + }); let auth = pair.client.auth(); let pending = moq_net_sim::spawn(async move { auth.add("never answered").await.map(|_| ()) }); @@ -1111,7 +454,7 @@ async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1154,7 +497,7 @@ async fn an_update_replaces_one_tokens_grant(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1185,7 +528,7 @@ async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { b"" | b"again" => Some(grant(&["a"], &[])), _ => None, }); @@ -1280,9 +623,7 @@ async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { .await; let mut requests = pair.requests.take().unwrap(); // Wait for the setup token to be queued, then hand it back to the queue's owner. - let Some(first) = requests.next().await else { - panic!("setup token") - }; + let first = requests.next().await.expect("setup token"); drop(first.accept(Grant::default())); let auth = pair.client.auth(); let pending = moq_net_sim::spawn(async move { auth.add("queued").await.map(drop) }); @@ -1329,10 +670,7 @@ async fn an_issued_grant_ends_with_its_session(version: &'static str) { }) .await; let mut requests = pair.requests.take().unwrap(); - let Some(setup) = requests.next().await else { - panic!("setup token") - }; - let issued = setup.accept(Grant::default()); + let issued = requests.next().await.expect("setup token").accept(Grant::default()); granted(&pair.client).await; pair.server_driver.abort(); @@ -1431,11 +769,9 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, - client_request_token: None, - ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| { + let mut issued = serve(pair.requests.take().unwrap(), |token| { token.is_empty().then(|| grant(&["up"], &["room"])) }); let (_, setup) = issued.next().await.unwrap(); @@ -1502,7 +838,7 @@ async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { b"" => Some(grant(&["a"], &[])), b"exact" => Some(Grant { publish: Patterns::from(Pattern::try_from("room/alice").unwrap()), @@ -1551,7 +887,7 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { b"" => Some(grant(&["a"], &[])), b"t1" => Some(grant(&["b"], &[])), _ => None, @@ -1594,7 +930,7 @@ async fn a_grant_too_large_for_one_message_is_unsupported(version: &'static str) let huge: Patterns = (0..count) .map(|i| Pattern::subtree(&format!("{i}{}", "x".repeat(len))).unwrap()) .collect(); - let mut issued = serve(pair.verifiers(), move |token| match token { + let mut issued = serve(pair.requests.take().unwrap(), move |token| match token { b"" => Some(grant(&["a"], &[])), b"huge" => Some(Grant { publish: huge.clone(), @@ -1626,7 +962,7 @@ async fn a_grant_update_too_large_for_one_message_is_unsupported(version: &'stat ..Default::default() }) .await; - let mut issued = serve(pair.verifiers(), |token| { + let mut issued = serve(pair.requests.take().unwrap(), |token| { Some(grant(if token.is_empty() { &["a"] } else { &["b"] }, &[])) }); let (_, _setup) = issued.next().await.unwrap(); @@ -1682,7 +1018,7 @@ async fn pattern_grants_arrive_exactly(version: &'static str) { expires: None, }) }; - let mut issued = serve(pair.verifiers(), table); + let mut issued = serve(pair.requests.take().unwrap(), table); let (_, _setup) = issued.next().await.unwrap(); assert_eq!(granted(&pair.client).await, table(b"").unwrap()); @@ -1760,9 +1096,7 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { // Hold the answer, so the peer's discovery request is in long before the grant. let mut requests = pair.requests.take().unwrap(); - let Some(setup) = requests.next().await else { - panic!("setup token") - }; + let setup = requests.next().await.expect("setup token"); let leaked = moq_net_sim::timeout( Duration::from_millis(100), wait_announced(&relay.consume(), "foo/bar", true), 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()); diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index f9e0d31b08..20c71f8b58 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -4,7 +4,7 @@ //! QUIC settings ([`crate::quic::Config`]) a binary shares with its accept half. The //! accept side is [`crate::server`]. -use crate::connection::{Goaway, RequestToken}; +use crate::connection::Goaway; use crate::{Addrs, Backoff, Connection, Error}; #[cfg(all(feature = "websocket", feature = "noq"))] use std::future::Future; @@ -42,9 +42,6 @@ impl Config { #[derive(Clone)] pub struct Client { moq: moq_net::Client, - /// The request token from [`crate::connect::Config::with_request_token`], which seeds each - /// [`Connection`]'s own. - pub(crate) request_token: Option, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list @@ -147,7 +144,6 @@ impl Client { moq: moq_net::Client::new() .with_versions(versions.clone()) .with_extensions(config.extensions), - request_token: config.request_token.clone().map(|token| token.0), #[cfg(any( feature = "noq", feature = "iroh", @@ -325,7 +321,7 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr, _token: &RequestToken) -> crate::Result { + pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable noq, iroh, websocket, tcp, or uds feature", )) @@ -344,10 +340,10 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { + pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. - let attempt = Box::pin(self.connect_inner(addr, token)); + let attempt = Box::pin(self.connect_inner(addr)); // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever @@ -386,7 +382,7 @@ impl Client { feature = "tcp", feature = "uds" ))] - async fn connect_inner(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { + async fn connect_inner(&self, addr: crate::connect::Addr) -> crate::Result { let url = addr.url().clone(); // Transports with no request URI of their own advertise the request target in the // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where @@ -408,7 +404,6 @@ impl Client { let session = connect_session( &moq, crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - token, ) .await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); @@ -428,7 +423,6 @@ impl Client { let session = connect_session( &moq, crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - token, ) .await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); @@ -442,7 +436,6 @@ impl Client { let session = connect_session( &moq, crate::transport::Session::new(session, moq_net::transport::Binding::InBand), - token, ) .await?; return Ok(Dialed::new(session, crate::Transport::Unix)); @@ -452,7 +445,7 @@ impl Client { // nothing to race and the fallback's answer is the connect's verdict. #[cfg(feature = "websocket")] if matches!(url.scheme(), "ws" | "wss") { - return self.connect_websocket(addr, token).await; + return self.connect_websocket(addr).await; } // iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw @@ -475,7 +468,7 @@ impl Client { crate::iroh::Binding::Raw => moq_net::transport::Binding::InBand, crate::iroh::Binding::H3 => moq_net::transport::Binding::Uri, }; - let session = connect_session(&moq, crate::transport::Session::new(session, binding), token).await?; + let session = connect_session(&moq, crate::transport::Session::new(session, binding)).await?; return Ok(Dialed::new(session, crate::Transport::Iroh)); } @@ -495,19 +488,19 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(&moq, addr, quic_handle, token).await; + return self.race_moq_connect(&moq, addr, quic_handle).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - let session = connect_session(&moq, session, token).await?; + let session = connect_session(&moq, session).await?; return Ok(Dialed::new(session, quic_transport(&url))); } } #[cfg(feature = "websocket")] - return self.connect_websocket(addr, token).await; + return self.connect_websocket(addr).await; #[cfg(not(feature = "websocket"))] return Err(Error::NoBackend("no QUIC backend matched; this should not happen")); @@ -516,14 +509,13 @@ impl Client { /// Connect over WebSocket alone. qmux over WebSocket carries the path in its request /// URI, so the plain builder is used: repeating it in the SETUP is a protocol violation. #[cfg(feature = "websocket")] - async fn connect_websocket(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { + async fn connect_websocket(&self, addr: crate::connect::Addr) -> crate::Result { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; let session = connect_session( &self.moq, crate::transport::Session::new(session, moq_net::transport::Binding::Uri), - token, ) .await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) @@ -544,7 +536,6 @@ impl Client { moq: &moq_net::Client, addr: crate::connect::Addr, quic: Q, - token: &RequestToken, ) -> crate::Result where Q: Future>, @@ -563,12 +554,11 @@ impl Client { }; match race_transport_connect(&url, quic, websocket).await? { - TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic, token).await?, transport)), + TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), TransportRace::WebSocket(session) => { let session = connect_session( &self.moq, crate::transport::Session::new(session, moq_net::transport::Binding::Uri), - token, ) .await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) @@ -783,15 +773,10 @@ fn race_error(quic: Error, websocket: Error) -> Error { async fn connect_session( client: &moq_net::Client, transport: S, - token: &RequestToken, -) -> crate::Result { +) -> Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; - // Before the driver runs, so the first request carries the newest token. A session that - // cannot carry one (moq-lite) fails the dial as Unsupported, which is terminal, rather - // than drop it. - token.attach(session.auth())?; use tracing::Instrument; tokio::spawn(crate::panic::isolate(moq_net::time::run(driver)).instrument(tracing::Span::current())); Ok(session) @@ -1461,7 +1446,7 @@ mod tests { let dialed = tokio::time::timeout( std::time::Duration::from_secs(10), - client.race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()), + client.race_moq_connect(&client.moq, ws_addr, quic), ) .await .expect("client connect timed out") @@ -1554,7 +1539,7 @@ mod tests { )) }); let cc = client - .race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()) + .race_moq_connect(&client.moq, ws_addr, quic) .await .expect("client connect failed"); @@ -1775,8 +1760,7 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let token = RequestToken::default(); - let mut attempt = Box::pin(client.dial(url.into(), &token)); + let mut attempt = Box::pin(client.dial(url.into())); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 61ca592d0a..789efcb0df 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,20 +361,6 @@ impl ConnectError { mod tests { use super::*; - /// A request token seeded on the dial config is kept out of its `Debug` and never - /// serialized, since a config is routinely logged and written back. - #[test] - fn a_request_token_stays_out_of_debug_and_serde() { - let config = Config::default().with_request_token(moq_net::setup::Token { - kind: 0, - value: b"s3cr3t".to_vec(), - }); - assert!(config.request_token.is_some()); - let debug = format!("{config:?}"); - assert!(!debug.contains("s3cr3t") && debug.contains("<6 bytes>"), "{debug}"); - assert!(!toml::to_string(&config).expect("serialize").contains("request_token")); - } - /// The dial config offers every extension unless told otherwise, and leaves the /// default out when serialized. #[test] @@ -493,16 +479,6 @@ failover_delay = "1s" } } -/// A request token held by a [`Config`], shown by type and length only so it stays out of logs. -#[derive(Clone, PartialEq, Eq)] -pub(crate) struct RequestToken(pub moq_net::setup::Token); - -impl std::fmt::Debug for RequestToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "kind={} <{} bytes>", self.0.kind, self.0.value.len()) - } -} - /// Whether `extensions` offers every extension, the default a config leaves out. pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { *extensions == moq_net::setup::Extensions::default() @@ -670,12 +646,6 @@ pub struct Config { #[usage(skip)] pub extensions: moq_net::setup::Extensions, - /// The `AUTHORIZATION TOKEN` every session presents on its own requests; see - /// [`with_request_token`](Self::with_request_token). - #[serde(skip)] - #[usage(skip)] - pub(crate) request_token: Option, - /// TLS trust and client-certificate settings (`--connect-tls-*`). #[usage(flatten)] #[serde(default)] @@ -755,7 +725,6 @@ impl Default for Config { timeout_arg: None, version: Vec::new(), extensions: Default::default(), - request_token: None, tls: Default::default(), once: None, reconnect: None, @@ -770,17 +739,6 @@ impl Default for Config { } impl Config { - /// Present `token` as the `AUTHORIZATION TOKEN` on each session's SUBSCRIBE and - /// PUBLISH_NAMESPACE requests. Experimental: the peer's application must verify it. - /// - /// It seeds the connection's request token, which [`crate::Auth::set_request_token`] - /// replaces across reconnects. A session that negotiates moq-lite cannot carry it, so - /// the connection ends with [`moq_net::Error::Unsupported`] instead of retrying. - pub fn with_request_token(mut self, token: moq_net::setup::Token) -> Self { - self.request_token = Some(RequestToken(token)); - self - } - /// Every released spelling this config was parsed from, across this section and /// the TLS and WebSocket ones it owns, each paired with what replaced it. /// diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 0a0a394866..7277cec4cd 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2,7 +2,6 @@ pub mod auth; pub use auth::Auth; -pub(crate) use auth::RequestToken; use std::task::{Poll, ready}; use std::time::Duration; @@ -502,8 +501,6 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, - /// Attached to each session as its handshake completes, detached once nothing is live. - request_token: RequestToken, } impl Shared { @@ -544,7 +541,7 @@ impl Shared { } /// Nothing is live. Clears the session so a handle can't be handed a closed one, - /// and drops the estimates and the request-token attachment that belonged to it. + /// and drops the estimates that belonged to it. fn disconnected(&self) { if let Ok(mut state) = self.state.write() { // Count one close per live session; a second call without an @@ -560,7 +557,6 @@ impl Shared { } let _ = self.send_bw.set(None); let _ = self.recv_bw.set(None); - self.request_token.detach(); } /// Record the error the loop gave up with, for [`Connection::closed`]. @@ -676,8 +672,6 @@ pub struct Connection { /// The last status returned by [`status`](Self::status), for change detection. /// Per-clone: a clone starts from its parent's cursor and diverges from there. last_reported: Option, - /// The request token presented across reconnects, which the loop seeds each session from. - request_token: RequestToken, } /// The connection loop, shared by every [`Connection`] clone and aborted when the @@ -713,9 +707,6 @@ impl Connection { let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); - let request_token = RequestToken::new(client.request_token.clone()); - let loop_token = request_token.clone(); - let task = tokio::spawn(async move { let reconnect = client.reconnect; let shared = Shared { @@ -723,7 +714,6 @@ impl Connection { send_bw, recv_bw, closed: task_closed, - request_token: loop_token, }; // Isolated inside `shared`, so a panic still records a terminal error // instead of closing the channels as if the caller had stopped it. @@ -751,7 +741,6 @@ impl Connection { send_bandwidth, recv_bandwidth, last_reported: None, - request_token, } } @@ -906,7 +895,7 @@ impl Connection { if let Some(mut old) = draining.take() { old.retire(); } - draining = Some(Draining::new(session, goaway.handover(msg.timeout()), shared)); + draining = Some(Draining::new(session, goaway.handover(msg.timeout()), &shared.state)); // After the predecessor is published, so a close woken by this // status finds it and honors its handover deadline. shared.migrating(); @@ -1050,7 +1039,7 @@ impl Connection { let url = addr.url(); tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone(), &shared.request_token)); + let mut dial = std::pin::pin!(client.dial(addr.clone())); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -1235,7 +1224,6 @@ impl Connection { Auth { connection: self.state.clone(), pacing: self.pacing, - request: self.request_token.clone(), } } @@ -1317,17 +1305,14 @@ struct Draining { deadline: std::pin::Pin>, /// Mirrors the session into [`State::predecessor`] for as long as this lives. state: kio::Producer, - /// Renews the session's token-authorized requests for as long as this lives. - request_token: RequestToken, } impl Draining { - fn new(session: moq_net::Session, handover: Duration, shared: &Shared) -> Self { + fn new(session: moq_net::Session, handover: Duration, state: &kio::Producer) -> Self { let deadline = Box::pin(tokio::time::sleep(handover)); - if let Ok(mut state) = shared.state.write() { + if let Ok(mut state) = state.write() { state.predecessor = Some((session.clone(), deadline.deadline())); } - shared.request_token.drain(); let closed = { let session = session.clone(); Box::pin(async move { @@ -1339,8 +1324,7 @@ impl Draining { session, closed, deadline, - state: shared.state.clone(), - request_token: shared.request_token.clone(), + state: state.clone(), } } @@ -1369,7 +1353,6 @@ impl Drop for Draining { if let Ok(mut state) = self.state.write() { state.predecessor = None; } - self.request_token.drained(); } } @@ -1997,206 +1980,12 @@ mod tests { config.init(Default::default()).expect("build client") } - /// A request token of kind 0 carrying `value`. - #[cfg(feature = "tcp")] - fn tok(value: &[u8]) -> moq_net::setup::Token { - moq_net::setup::Token { - kind: 0, - value: value.to_vec(), - } - } - - /// The connection owns the request token across the reconnect loop: the dial config seeds - /// the cell the loop presents on each (re)connected session, and renewing through - /// [`Connection::auth`] updates that same cell, so a renewal survives into the next session. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn the_connection_owns_the_request_token_across_the_loop() { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - let mut config = crate::connect::Config::default(); - config.tls.insecure = Some(true); - let client = config - .with_request_token(tok(b"token-one")) - .init(Default::default()) - .expect("client"); - - // A dead address: the loop never connects, but the handle and its cell exist at once. - let conn = client.connect(dead().await); - assert_eq!( - format!("{:?}", conn.request_token), - "kind=0 <9 bytes>", - "seeded by the config" - ); - - conn.auth().set_request_token(Some(tok(b"token-three"))).unwrap(); - assert_eq!( - format!("{:?}", conn.request_token), - "kind=0 <11 bytes>", - "renewing through auth() updates the seed the next session reads" - ); - } - - /// A request token on a moq-lite session fails the connection at once, even with unlimited - /// reconnects: the server negotiates moq-lite again, so a retry cannot carry the token. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn a_request_token_on_moq_lite_fails_at_once() { - let mut cfg = crate::listen::Config::default(); - cfg.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); - cfg.version = vec!["moq-lite-06".parse().unwrap()]; - let mut listener = cfg - .init(Default::default()) - .expect("server") - .listen() - .await - .expect("listen"); - let addr = listener.tcp_local_addr().expect("tcp listener bound"); - tokio::spawn(async move { - let mut sessions = Vec::new(); - while let Some(request) = listener.accept().await { - if let Ok(session) = request.ok().await { - sessions.push(session); - } - } - }); - - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let mut config = crate::connect::Config::default(); - config.tls.insecure = Some(true); - config.backoff.timeout = Some(Duration::ZERO); - let client = config - .with_request_token(tok(b"token")) - .init(Default::default()) - .expect("client"); - let connection = client.connect(url(&format!("tcp://{addr}/"))); - let err = tokio::time::timeout(Duration::from_secs(10), connection.closed()) - .await - .expect("kept retrying a session that cannot carry the token") - .expect_err("connected without carrying the token"); - assert!(matches!(err, Error::MoqNet(moq_net::Error::Unsupported)), "{err}"); - } - - /// A draft-16 server for the first-request tests: it accepts one session, reports on - /// `paused` once the client's SETUP has arrived (the client's handshake then waits on this - /// side's reply), holds its SERVER_SETUP until `release` fires, and sends the first - /// request-borne token it verifies on the returned receiver. - /// - /// It declines Solicit, so each announce rides its own PUBLISH_NAMESPACE, the request that - /// carries a token inline. - #[cfg(feature = "tcp")] - async fn first_request_token( - paused: tokio::sync::oneshot::Sender<()>, - release: tokio::sync::oneshot::Receiver<()>, - ) -> (Url, tokio::sync::oneshot::Receiver>) { - let mut cfg = crate::listen::Config::default(); - cfg.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); - cfg.version = vec!["moq-transport-16".parse().unwrap()]; - cfg.extensions.solicit = false; - let mut listener = cfg - .init(Default::default()) - .expect("server") - .listen() - .await - .expect("listen"); - let addr = listener.tcp_local_addr().expect("tcp listener bound"); - let url = url(&format!("tcp://{addr}/")); - let (first_tx, first_rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { - let request = listener.accept().await.expect("the client dials"); - let _ = paused.send(()); - let _ = release.await; - - let request = request.with_subscriber(crate::origin::spawn()); - let mut requests = request.auth().scoped().expect("request tokens are taken before ok"); - let _session = request.ok().await.expect("accept"); - let mut first_tx = Some(first_tx); - let mut scoped = Vec::new(); - while let Some(req) = requests.next().await { - if let Some(tx) = first_tx.take() { - let _ = tx.send(req.token().value.clone()); - } - scoped.push(req.accept(moq_net::auth::Grant::all())); - } - }); - (url, first_rx) - } - - /// A draft-16 client presenting `token`, with a broadcast announced before it connects so - /// its first PUBLISH_NAMESPACE is pending when the session's driver starts. It declines the - /// AUTH extension, so the request token is the only authorizing artifact. - #[cfg(feature = "tcp")] - fn announcing_client(token: moq_net::setup::Token, url: Url) -> (Connection, moq_net::broadcast::Producer) { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let origin = crate::origin::spawn(); - let broadcast = origin.create_broadcast("room/alice").expect("broadcast"); - broadcast.announce(Default::default()).expect("announce"); - - let mut cfg = crate::connect::Config::default(); - cfg.tls.insecure = Some(true); - cfg.version = vec!["moq-transport-16".parse().unwrap()]; - cfg.extensions.auth = false; - let client = cfg - .with_request_token(token) - .init(Default::default()) - .expect("client") - .with_publisher(origin.consume()) - .with_reconnect(false); - (client.connect(url), broadcast) - } - - /// The dial config's token reaches the FIRST request on a connected session: the session - /// is attached to the connection's token before its driver starts, and nothing re-sets it - /// afterwards, so a session started without it would present nothing. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn the_first_request_on_a_connected_session_carries_the_token() { - let (paused_tx, _paused) = tokio::sync::oneshot::channel(); - let (release, release_rx) = tokio::sync::oneshot::channel(); - let (url, first) = first_request_token(paused_tx, release_rx).await; - release.send(()).unwrap(); - - let (_conn, _broadcast) = announcing_client(tok(b"a"), url); - let first = tokio::time::timeout(Duration::from_secs(10), first) - .await - .expect("the first request never reached the server") - .expect("server ended"); - assert_eq!(first, b"a", "the first request carries the configured token"); - } - - /// A token set while the handshake is still pending is the one the first request carries: - /// the session is seeded when it is created, not from a snapshot taken before dialing. The - /// server holds its SERVER_SETUP until the renewal returns, and draft-16 carries the token - /// inline on the first PUBLISH_NAMESPACE. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn a_renewal_during_the_handshake_reaches_the_first_request() { - let (paused_tx, paused) = tokio::sync::oneshot::channel(); - let (release, release_rx) = tokio::sync::oneshot::channel(); - let (url, first) = first_request_token(paused_tx, release_rx).await; - - let (conn, _broadcast) = announcing_client(tok(b"a"), url); - paused.await.expect("the client's SETUP reached the server"); - conn.auth().set_request_token(Some(tok(b"b"))).unwrap(); - release.send(()).unwrap(); - - let first = tokio::time::timeout(Duration::from_secs(10), first) - .await - .expect("the first request never reached the server") - .expect("server ended"); - assert_eq!( - first, b"b", - "the first request carries the renewal, not the dial-time token" - ); - } - fn shared() -> Shared { Shared { state: kio::Producer::default(), send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), - request_token: RequestToken::default(), } } diff --git a/rs/moq-tokio/src/connection/auth.rs b/rs/moq-tokio/src/connection/auth.rs index 41b1d3bc7c..97a7a2b229 100644 --- a/rs/moq-tokio/src/connection/auth.rs +++ b/rs/moq-tokio/src/connection/auth.rs @@ -14,27 +14,9 @@ use crate::{Error, abort::AbortOnDrop}; pub struct Auth { pub(super) connection: kio::Consumer, pub(super) pacing: Pacing, - pub(super) request: RequestToken, } impl Auth { - /// Present `token` as the `AUTHORIZATION TOKEN` on this connection's SUBSCRIBE and - /// PUBLISH_NAMESPACE requests, now and after reconnects, or stop presenting one with - /// `None`. Experimental: the peer's application must verify it, and success means only - /// that the token was set. See [`moq_net::auth::Handle::set_request_token`]. - /// - /// Replacing it renews the token-authorized requests in band on the live session and on - /// any session still draining after a GOAWAY, except a draft 14 or 15 advertisement, - /// which has no update. - /// - /// # Errors - /// - /// [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose requests - /// carry no token. The token is then not kept, so later sessions keep the one set before. - pub fn set_request_token(&self, token: Option) -> Result<(), moq_net::Error> { - self.request.set(token) - } - /// Watch the current session's union, clearing it while disconnected. pub fn grant(&self) -> Watch { Watch { @@ -68,123 +50,6 @@ impl Auth { } } -/// The request token a connection presents and the sessions it presents it on, shared -/// across its sessions and shown by length only so it stays out of logs. -/// -/// Seeding a new session ([`attach`](Self::attach)) and replacing the token -/// ([`set`](Self::set)) each read and write under one lock, so neither can leave a session -/// on an older token than the one set last, and a token set while a session is still -/// handshaking is the one it starts with. The lock is held only across synchronous work. -#[derive(Clone, Default)] -pub(crate) struct RequestToken(std::sync::Arc>); - -#[derive(Default)] -struct Presenting { - token: Option, - /// The newest session, which a renewal reaches; `None` while nothing is live. - session: Option>, - /// The session a GOAWAY migration replaced, still serving its requests until the - /// connection retires it, which a renewal reaches too. - draining: Option>, -} - -/// Where a request token is presented: a session's auth handle, which never keeps the -/// session itself alive. -pub(crate) trait Present: Send { - /// Present `token` on this session's requests from now on (or none), or refuse it when - /// the session cannot carry one (moq-lite). - fn present(&self, token: Option) -> Result<(), moq_net::Error>; -} - -impl Present for auth::Handle { - fn present(&self, token: Option) -> Result<(), moq_net::Error> { - self.set_request_token(token) - } -} - -impl RequestToken { - pub(crate) fn new(token: Option) -> Self { - Self(std::sync::Arc::new(std::sync::Mutex::new(Presenting { - token, - session: None, - draining: None, - }))) - } - - /// Replace the token, on the newest session and a draining one too, and seed every later - /// session with it. - /// - /// Errors when the newest session cannot carry it, keeping the token set before: one the - /// live session refused would only fail the next reconnect for good. A draining session - /// that cannot carry it keeps what it had. - fn set(&self, token: Option) -> Result<(), moq_net::Error> { - let mut presenting = self.0.lock().unwrap(); - if let Some(session) = &presenting.session { - session.present(token.clone())?; - } - if let Some(session) = &presenting.draining { - let _ = session.present(token.clone()); - } - presenting.token = token; - Ok(()) - } - - /// Seed `session` with the current token and make it the newest session later renewals - /// reach. - /// - /// Errors when the session cannot carry the token set, which leaves it unattached. - #[cfg_attr( - not(any( - feature = "noq", - feature = "iroh", - feature = "websocket", - feature = "tcp", - all(feature = "uds", unix) - )), - allow(dead_code) - )] - pub(crate) fn attach(&self, session: impl Present + 'static) -> Result<(), moq_net::Error> { - let mut presenting = self.0.lock().unwrap(); - if presenting.token.is_some() { - session.present(presenting.token.clone())?; - } - presenting.session = Some(Box::new(session)); - Ok(()) - } - - /// Stop reaching the newest session once nothing is live; renewals then reach only one - /// still draining, and the seed. - pub(crate) fn detach(&self) { - // Also runs from `Shared`'s drop, so take a poisoned lock rather than panic while unwinding. - self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).session = None; - } - - /// Keep renewing the newest session while a GOAWAY migration drains it, until - /// [`drained`](Self::drained). - pub(crate) fn drain(&self) { - let mut presenting = self.0.lock().unwrap(); - presenting.draining = presenting.session.take(); - } - - /// Stop reaching the session [`drain`](Self::drain) kept, once the connection retires it. - pub(crate) fn drained(&self) { - // Runs from `Draining`'s drop, so take a poisoned lock rather than panic while unwinding. - self.0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .draining = None; - } -} - -impl std::fmt::Debug for RequestToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.0.lock().unwrap().token { - Some(token) => write!(f, "kind={} <{} bytes>", token.kind, token.value.len()), - None => write!(f, ""), - } - } -} - /// A presented token; dropping it withdraws it from live and draining sessions. pub struct Token { _task: AbortOnDrop, @@ -414,252 +279,6 @@ async fn present( } } -#[cfg(test)] -mod request_token_tests { - use super::*; - - impl RequestToken { - fn get(&self) -> Option { - self.0.lock().unwrap().token.clone() - } - } - - /// A request token of kind 0 carrying `value`. - fn tok(value: &[u8]) -> moq_net::setup::Token { - moq_net::setup::Token { - kind: 0, - value: value.to_vec(), - } - } - - /// The request token is shown by length only, never printed. - #[test] - fn request_token_debug_is_redacted() { - let token = RequestToken::new(Some(tok(b"s3cr3t"))); - assert_eq!(format!("{token:?}"), "kind=0 <6 bytes>"); - assert_eq!(format!("{:?}", RequestToken::default()), ""); - } - - /// A session double that records every token it is presented and parks inside its first - /// [`Present::present`] until the test releases it: exactly where a seed or a renewal - /// writes the session, after reading the token it writes. - struct Parked { - presented: std::sync::Arc>>, - /// Taken by the first present, which reports that it parked and waits to be released. - park: std::sync::Mutex, std::sync::Arc)>>, - } - - impl Parked { - fn new(presented: &std::sync::Arc>>) -> Self { - Self { - presented: presented.clone(), - park: Default::default(), - } - } - } - - impl Present for Parked { - fn present(&self, token: Option) -> Result<(), moq_net::Error> { - let token = token.expect("the tests present a token"); - let park = self.park.lock().unwrap().take(); - if let Some((parked, release)) = park { - parked.send(()).unwrap(); - release.wait(); - } - self.presented.lock().unwrap().push(token); - Ok(()) - } - } - - /// Park the first present `write` makes on another thread, then race `renew` against it, - /// returning every token the session was presented in order. - /// - /// While `write` is parked mid-present, the cell's lock must still be held, so `renew` - /// cannot land between `write`'s read and its write and be overwritten by the older token. - fn race_renewal( - token: &RequestToken, - write: impl FnOnce(&RequestToken, Parked) + Send + 'static, - renew: moq_net::setup::Token, - ) -> Vec { - let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let release = std::sync::Arc::new(std::sync::Barrier::new(2)); - let (step, steps) = std::sync::mpsc::channel(); - let parked = Parked::new(&presented); - *parked.park.lock().unwrap() = Some((step, release.clone())); - - let writer = { - let token = token.clone(); - std::thread::spawn(move || write(&token, parked)) - }; - steps.recv().expect("the write under test never presented a token"); - assert!( - token.0.try_lock().is_err(), - "a renewal could land between the parked write's read and its write" - ); - - let renewer = { - let token = token.clone(); - std::thread::spawn(move || token.set(Some(renew)).unwrap()) - }; - release.wait(); - writer.join().expect("writer panicked"); - renewer.join().expect("renewer panicked"); - std::mem::take(&mut *presented.lock().unwrap()) - } - - /// A session that cannot carry a request token (moq-lite). - struct Refuses; - - impl Present for Refuses { - fn present(&self, token: Option) -> Result<(), moq_net::Error> { - match token { - Some(_) => Err(moq_net::Error::Unsupported), - None => Ok(()), - } - } - } - - /// A session double that records every token it is presented. - #[derive(Clone, Default)] - struct Records { - presented: std::sync::Arc>>>, - } - - impl Records { - fn presented(&self) -> Vec> { - self.presented.lock().unwrap().clone() - } - } - - impl Present for Records { - fn present(&self, token: Option) -> Result<(), moq_net::Error> { - self.presented.lock().unwrap().push(token); - Ok(()) - } - } - - /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial - /// does too, and a renewal reaching it fails without replacing the token later sessions - /// are seeded with, so the next reconnect does not fail for good on it. - #[test] - fn a_session_that_cannot_carry_the_token_refuses_it() { - let token = RequestToken::new(Some(tok(b"token-a"))); - assert!(matches!(token.attach(Refuses), Err(moq_net::Error::Unsupported))); - - let token = RequestToken::new(None); - token.attach(Refuses).expect("nothing to present"); - assert!(matches!( - token.set(Some(tok(b"token-b"))), - Err(moq_net::Error::Unsupported) - )); - assert_eq!(token.get(), None, "a refused token is not kept"); - token.set(None).expect("clearing is always carried"); - } - - /// Clearing the token stops presenting it on the live session and seeds later ones with none. - #[test] - fn clearing_the_token_reaches_the_session() { - let session = Records::default(); - let token = RequestToken::new(Some(tok(b"token-a"))); - token.attach(session.clone()).unwrap(); - token.set(None).unwrap(); - assert_eq!(session.presented(), [Some(tok(b"token-a")), None]); - assert_eq!(token.get(), None, "later sessions carry none"); - } - - /// A session replaced by a GOAWAY migration still serves its requests while it drains, so - /// a renewal reaches it too until the connection retires it, and the newest session either - /// way. - #[test] - fn a_renewal_reaches_a_draining_session_until_it_retires() { - let (a, b) = (tok(b"token-a"), tok(b"token-b")); - let token = RequestToken::new(Some(a.clone())); - let old = Records::default(); - let new = Records::default(); - token.attach(old.clone()).unwrap(); - token.drain(); - token.set(Some(b.clone())).unwrap(); - assert_eq!( - old.presented(), - [Some(a.clone()), Some(b.clone())], - "renewed between sessions" - ); - - token.attach(new.clone()).unwrap(); - token.set(Some(a.clone())).unwrap(); - assert_eq!( - old.presented(), - [Some(a.clone()), Some(b.clone()), Some(a.clone())], - "renewed while draining" - ); - assert_eq!(new.presented(), [Some(b.clone()), Some(a.clone())]); - - token.drained(); - token.set(Some(b.clone())).unwrap(); - assert_eq!(old.presented().len(), 3, "a retired session is presented nothing more"); - assert_eq!(new.presented(), [Some(b.clone()), Some(a), Some(b)]); - } - - /// A reconnect seeding its new session cannot replay the token it read over a renewal that - /// lands meanwhile: the session ends on the renewal, as the reconnect seed does. - #[test] - fn a_renewal_racing_the_reconnect_seed_is_not_replayed() { - let a = tok(b"token-a"); - let b = tok(b"token-b"); - let token = RequestToken::new(Some(a.clone())); - - let presented = race_renewal(&token, |token, session| token.attach(session).unwrap(), b.clone()); - - assert_eq!(presented, [a, b.clone()], "seeded with A, then renewed to B"); - assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); - } - - /// Two renewals present in the order they land, so the session ends on the one that landed - /// last, the same token the next reconnect is seeded with. - #[test] - fn concurrent_renewals_leave_the_session_on_the_last() { - let a = tok(b"token-a"); - let b = tok(b"token-b"); - let token = RequestToken::new(None); - - let presented = race_renewal( - &token, - { - let a = a.clone(); - move |token, session| { - // Nothing to seed, so the attach presents nothing and the renewal parks. - token.attach(session).unwrap(); - token.set(Some(a)).unwrap(); - } - }, - b.clone(), - ); - - assert_eq!(presented, [a, b.clone()], "renewed to A, then to B"); - assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); - } - - /// Once nothing is live a renewal only updates the seed, and the next attach picks it up. - #[test] - fn a_renewal_between_sessions_seeds_the_next() { - let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let session = || Parked::new(&presented); - let token = RequestToken::new(Some(tok(b"token-a"))); - - token.attach(session()).unwrap(); - token.detach(); - token.set(Some(tok(b"token-b"))).unwrap(); - assert_eq!( - presented.lock().unwrap().len(), - 1, - "a renewal reached a detached session" - ); - - token.attach(session()).unwrap(); - assert_eq!(presented.lock().unwrap().last().cloned(), Some(tok(b"token-b"))); - } -} - #[cfg(all(test, feature = "tcp"))] mod tests { use super::*; @@ -804,7 +423,6 @@ mod tests { Auth { connection: connection.consume(), pacing: Pacing::new(&super::super::Backoff::default()), - request: RequestToken::default(), } } diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 8d020a0fc0..29ff84c9b2 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -245,10 +245,6 @@ impl Error { | Self::WindowRange(_) | Self::QlogUnsupported | Self::Connect(_) => true, - // Something this dial needs the negotiated session cannot do, such as carry a - // configured request token on moq-lite. The same peer negotiates the same way - // again, so retrying cannot help. - Self::MoqNet(moq_net::Error::Unsupported) => true, #[cfg(feature = "noq")] Self::Noq(err) => err.is_terminal(), #[cfg(feature = "websocket")] @@ -395,12 +391,6 @@ pub type Result = std::result::Result; mod tests { use super::*; - /// The same peer negotiates the same way again, so a dial it cannot support fails for good. - #[test] - fn an_unsupported_dial_is_terminal() { - assert!(Error::MoqNet(moq_net::Error::Unsupported).is_terminal()); - } - #[test] fn a_transport_race_is_terminal_only_when_every_arm_is_terminal() { let local = || Error::WebSocket(Arc::new(crate::websocket::Error::UnsupportedScheme("bad".into()))); From c4c841f631a1b359432be87168383c638203fb3e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 09:45:03 -0700 Subject: [PATCH 46/53] fix(net): a namespace refresh sends the newest token to each announce A refresh renews live announces one answer at a time, so the token can change midway. Each announce now carries the token current when its request ID is taken, and a cleared token stops the refresh, so a superseded or cleared token never reaches an announce that had not presented it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 96 +++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 376dee445b..e2e917f660 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -2883,14 +2883,18 @@ where /// is not a renewal and sends nothing. A refusal withdraws the announce, since the receiver /// closes its stream on a renewal it cannot grant; the retry re-offers it carrying the new /// token, honoring any retry interval the refusal named. + /// + /// Each announce waits for the previous one's answer, so the token can change midway. The + /// rest then carry the newest token, or nothing once it is cleared: a superseded token + /// never reaches an announce that had not presented it. async fn refresh_request_token( &self, ns: &mut Namespaces, token: Option, ) -> Result<(), Error> { - let Some(token) = token else { + if token.is_none() { return Ok(()); - }; + } if matches!(self.version, Version::Draft14 | Version::Draft15) { return Ok(()); } @@ -2904,7 +2908,16 @@ where .map(|(suffix, _)| suffix.clone()) .collect(); for suffix in suffixes { + // Checked before taking an ID, so a cleared token spends none. + if self.request_token.peek().is_none() { + return Ok(()); + } let request_id = self.control.next_request_id(&self.runtime).await?; + // The ID is spent, so it carries the newest token. Cleared during the wait, it goes + // unsent: on draft 16 that strands one ID of credit, which only that race costs. + let Some(token) = self.request_token.peek() else { + return Ok(()); + }; let Some(request) = ns.requests.get_mut(&suffix) else { continue; }; @@ -12755,6 +12768,85 @@ mod tests { assert!(renewed, "a replaced token must be re-presented on the live announce"); } + /// A token replaced while a refresh works through the live announces reaches the ones not + /// yet renewed in its place: the superseded token never goes to an announce that had not + /// presented it. + #[moq_net_sim::test] + async fn a_superseded_token_never_reaches_an_unrenewed_announce() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + let third = bytes::Bytes::from_static(&[0x03, 0x00, b'c', b'c']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let _mic = origin.announce("mic", crate::origin::Route::default()).unwrap(); + settle().await; + + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script.clone(), script]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + // Server IDs are odd: both announces (1, 3) and the first renewal (5) fit, and the + // second renewal (7) waits for credit. + let control = Control::new(Some(RequestId(7)), false); + let token = crate::RequestToken::new(Some(typed(&first))); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + control.clone(), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 2 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &first), 2, "both announces carry the first token"); + + token.set(Some(typed(&second))); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 1 { + break; + } + settle().await; + } + for _ in 0..20 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log, &second), + 1, + "one announce renewed, the other waits for credit" + ); + + token.set(Some(typed(&third))); + control.max_request_id(RequestId(100)); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log, &second), + 1, + "the superseded token reached a second announce" + ); + assert!(occurrences(&log, &third) >= 2, "both announces carry the newest token"); + } + /// A namespace advertised without a token stands on the connection grant, so setting a /// token later re-presents nothing on it: the receiver would refuse a token on an /// advertisement it admitted without one. From e0fa8a5cf68b477d3342ec153164c964a73039a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:35:12 -0700 Subject: [PATCH 47/53] fix(auth): refuse an empty request token before the acceptor An empty token on AUTH means the connection's own credential, so one riding a request reached the acceptor as that credential: the relay bound its connection credential to the request, and on drafts without AUTH the session lost its grant when that request ended. Refuse it as unsupported, like a typed token. Also say in the standard notes that a side which neither authorizes the peer nor verifies tokens, JavaScript included, ignores a request token. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 2 +- rs/moq-net/src/auth.rs | 32 +++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 6c825ef743..6cce69e992 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** 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 on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` is verified like an in-band session token and authorizes that one request alone, even where our own authorization of the peer already covers it; it never widens the session. It is refused with `NOT_SUPPORTED` when the application verifies no tokens, or when its Token Type is anything but 0 (out of band). A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. +- **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. A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index fbf5e356c1..adac4b0e1e 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -415,16 +415,18 @@ impl Handle { /// /// 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, or for a token - /// type other than [`Token::OUT_OF_BAND`](crate::setup::Token::OUT_OF_BAND), which an AUTH - /// token cannot name, the verdict is [`Error::Unsupported`]. `hold` is the request's slot, - /// kept while the application verifies the token. + /// 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 { + if token.kind != crate::setup::Token::OUT_OF_BAND || token.value.is_empty() { return RequestVerdict { issue: None }; } let issue = kio::Shared::new(Issue { @@ -1866,6 +1868,26 @@ mod request_token_tests { ); } + /// 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 From 86e34b46145d7561500b658164742a19c40d1150 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 10:41:20 -0700 Subject: [PATCH 48/53] docs: scope the one-token refusal to Rust JavaScript verifies no request token, so it ignores every one, distinct or not. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/concept/standard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 6cce69e992..cb3d8f0e6f 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** 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`. 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. A request carrying more than one token is refused with `NOT_SUPPORTED`. A token-authorized `SUBSCRIBE` still stays within the grant the peer gave us. 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. +- **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. 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. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. From dd197560ef324c9ddcbe610a5b2dd7ea14d3dc0c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 11:39:12 -0700 Subject: [PATCH 49/53] refactor(auth): drop the 128-token pending bound The bound only threw, and the peer's credit already bounds pending tokens: each AUTH token holds its stream open and each request token holds its request's slot until answered or withdrawn. A withdrawn token now leaves the queue on every push, in Rust and JS, so a peer that keeps withdrawing cannot grow it. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth.test.ts | 42 +------------------ js/net/src/auth.ts | 3 ++ js/net/src/auth_session.ts | 10 ++--- rs/moq-net/src/auth.rs | 84 +++++++++++++------------------------- 4 files changed, 37 insertions(+), 102 deletions(-) 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..e46b4a084f 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -216,17 +216,15 @@ class RequestQueue implements Requests { #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. + this.#queue = this.#queue.filter((queued) => !queued.ended); const waiter = this.#waiters.shift(); if (waiter) waiter(request); else this.#queue.push(request); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index adac4b0e1e..4c0c1f1788 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -390,9 +390,9 @@ impl Handle { /// origin handles allow for the connection's credential, and refuses any other /// 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 /// @@ -885,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, @@ -904,7 +904,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(); @@ -912,15 +912,9 @@ impl Pending { if state.closed { return; } - // A withdrawn token must not occupy a slot while the acceptor is busy. - if state.requests.len() >= 128 { - state.requests.retain(|request| request.issue().read().peer.is_none()); - } - 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. + state.requests.retain(|request| request.issue().read().peer.is_none()); state.requests.push_back(request); } @@ -1682,61 +1676,41 @@ 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() { - 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, - .. - }) - )); - } - - /// Request tokens share the session tokens' bound, so a full queue refuses one. - #[test] - fn a_full_queue_refuses_a_request_token() { + 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 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, None); - assert!(matches!( - verdict.poll_grant(&kio::Waiter::noop()), - Poll::Ready(Err(Refusal { - error: Error::Session(SessionError::TooManyRequests), - .. - })) - )); + 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"); } + /// A withdrawn token leaves the queue on the next push, 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())); + let withdrawn = kio::Shared::::default(); + for _ in 0..3 { + queue.push(Request::new(Bytes::from_static(b"withdrawn"), withdrawn.clone())); } - expired.lock().peer = Some(Error::Cancel); + withdrawn.lock().peer = Some(Error::Cancel); queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); + assert_eq!(queue.state.read().requests.len(), 1, "only the live token stays queued"); let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; From df814aba8178bd224afc4942c0537ae2db41da7b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 11:44:46 -0700 Subject: [PATCH 50/53] style: format the credit-bound test Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 4c0c1f1788..0c6c32edbc 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1688,11 +1688,19 @@ mod tests { 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"); + 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"); + 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"); } From 91a113cfdf256ca5434680cb6eb61ffb25ac04a6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 12:30:19 -0700 Subject: [PATCH 51/53] fix(auth): hold announce subscriptions to the announce's token A token-authorized PUBLISH_NAMESPACE kept its route outside the limit, but every subscription under it was still gated on the session scope alone, so it was refused Unauthorized. Subscriptions under an announced namespace now use a gate that lets the announce stand in for the scope while it stays authorized, and ends them once it does not. Also check body exhaustion before refusing a request for its tokens, so trailing junk still closes the session, and decode the token last so other malformed fields win. Prune withdrawn pending tokens only once the queue doubles, keeping a push amortized O(1). Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- js/net/src/auth_session.ts | 8 +- rs/moq-net/src/auth.rs | 62 ++++++-- rs/moq-net/src/ietf/publish.rs | 4 +- rs/moq-net/src/ietf/publish_namespace.rs | 4 +- rs/moq-net/src/ietf/publisher.rs | 30 ++++ rs/moq-net/src/ietf/subscribe_namespace.rs | 8 +- rs/moq-net/src/ietf/subscriber.rs | 175 ++++++++++++++++++--- 7 files changed, 249 insertions(+), 42 deletions(-) diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index e46b4a084f..493703623f 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -213,6 +213,9 @@ 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; @@ -224,7 +227,10 @@ class RequestQueue implements Requests { } // 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. - this.#queue = this.#queue.filter((queued) => !queued.ended); + 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); else this.#queue.push(request); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0c6c32edbc..1acef345ea 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -894,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, } @@ -914,7 +917,10 @@ impl Pending { } // 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. - state.requests.retain(|request| request.issue().read().peer.is_none()); + 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); } @@ -1507,27 +1513,32 @@ pub(crate) struct Gate { 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. -#[derive(Clone, Copy)] #[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, } } @@ -1540,22 +1551,53 @@ impl Gate { } } + /// 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)); + 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 => permit.granted(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, + } } } @@ -1705,20 +1747,20 @@ mod tests { assert_eq!(std::sync::Arc::strong_count(&slot), 1, "answered, the slot is released"); } - /// A withdrawn token leaves the queue on the next push, so a peer that keeps presenting and + /// 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_leave_the_queue() { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let queue = handle.acceptor().unwrap(); + queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); let withdrawn = kio::Shared::::default(); - for _ in 0..3 { + 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"); } - withdrawn.lock().peer = Some(Error::Cancel); - queue.push(Request::new(Bytes::from_static(b"live"), Default::default())); - assert_eq!(queue.state.read().requests.len(), 1, "only the live token stays queued"); let Poll::Ready(Some(request)) = requests.poll_next(&kio::Waiter::noop()) else { panic!("live request missing") }; diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index d01d5ce4f2..c7030bdd31 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -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::single(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 b50999b2f2..a4e69cec6d 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -47,8 +47,6 @@ impl PublishNamespace<'_> { cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - let authorization_token = super::token::single(authorization_token)?; - let cluster = match negotiated { true => Some(cluster::Advert { hops: hops.ok_or(DecodeError::InvalidValue)?, @@ -59,6 +57,8 @@ 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, diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 72180d1ae5..b0d045cc02 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -649,6 +649,10 @@ where ietf::Subscribe::ID => { let msg = match ietf::Subscribe::decode_msg(&mut data, this.version) { 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?, @@ -669,6 +673,10 @@ where ietf::Fetch::ID => { let msg = match ietf::Fetch::decode_msg(&mut data, this.version) { 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?, @@ -704,6 +712,10 @@ where }; let (msg, options) = match decoded { 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(), err, serve)); } @@ -750,6 +762,10 @@ where ietf::TrackStatus::ID => { let msg = match ietf::TrackStatus::decode_msg(&mut data, this.version) { 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?, @@ -5989,6 +6005,20 @@ mod serve_tests { 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}"); } diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 6ce715e0b6..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, ); + 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, ); + 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, }) } } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 8c0b6209bf..212a35d66c 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1161,6 +1161,10 @@ where ietf::Publish::ID => { let msg = match ietf::Publish::decode_msg(&mut data, this.version) { 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?, @@ -1181,6 +1185,10 @@ where // the dispatcher turns into the protocol violation the draft requires. let msg = match ietf::PublishNamespace::decode_body(&mut data, this.version, peer.negotiated()) { 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?, @@ -1533,11 +1541,11 @@ where Err(err) if super::token::refuses_request(&err) => {} msg => { msg?; - if !data.is_empty() { - return Err(Error::WrongSize); - } } } + if !data.is_empty() { + return Err(Error::WrongSize); + } } match renewal.queue((id, body), self.version) { Ok(()) => continue, @@ -1627,6 +1635,10 @@ where // 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); + } let refusal = crate::auth::Refusal { reason: super::token::reason(&err).to_string(), error: Error::Decode(err), @@ -2147,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(); @@ -2327,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; }); } @@ -2339,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. @@ -5906,7 +5923,7 @@ 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) @@ -5956,7 +5973,7 @@ 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 @@ -6020,7 +6037,7 @@ 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. @@ -6078,7 +6095,7 @@ 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!( @@ -6204,7 +6221,7 @@ 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; @@ -6347,7 +6364,7 @@ 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 @@ -6426,7 +6443,7 @@ 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; @@ -6516,7 +6533,7 @@ 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; @@ -6594,7 +6611,7 @@ 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); @@ -8790,6 +8807,100 @@ mod tests { ); } + /// 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. @@ -9521,10 +9632,24 @@ mod tests { 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 session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); let (tasks, task_set) = crate::util::TaskSet::new(); std::mem::forget(task_set); let peer_setup = peer::PeerSetup::default(); @@ -12867,7 +12992,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; }); From 89bac27d16991a3bedbdb46f037f299c8dd1c771 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 12:49:13 -0700 Subject: [PATCH 52/53] style: format the run_subscribe test callers Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 212a35d66c..915229459e 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -5923,7 +5923,9 @@ mod tests { drop(consumer); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); moq_net_sim::timeout(std::time::Duration::from_secs(1), serving) @@ -5973,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, None, 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 @@ -6037,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, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); // Let the SUBSCRIBE go out, then retract the broadcast before any response. @@ -6095,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, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }), Allocated::TrackStatus => moq_net_sim::spawn(async move { assert!( @@ -6221,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, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -6364,7 +6374,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, None, 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 @@ -6443,7 +6455,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -6533,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, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; @@ -6611,7 +6627,9 @@ mod tests { let probe = subscriber.clone(); let serving = moq_net_sim::spawn(async move { - subscriber.run_subscribe(Path::new("broadcast"), dynamic, None, request).await; + subscriber + .run_subscribe(Path::new("broadcast"), dynamic, None, request) + .await; }); settle().await; let _subscription = track.subscribe(None); From f5b7088c1336ceec4e70dd3728fcfec3b27b701b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 13:07:20 -0700 Subject: [PATCH 53/53] fix(auth): an announce token must cover its whole namespace A PUBLISH_NAMESPACE routes everything beneath its namespace, and the subscriptions under a token-authorized announce now rely on it, so a grant naming only the exact namespace path would let the peer serve broadcasts outside it. Admission and renewal now require the grant's publish patterns to contain the namespace's subtree. Co-authored-by: Kyle Sletmoe Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 3 ++- doc/concept/standard.md | 2 +- rs/moq-net/src/auth.rs | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 5fdb194ca1..f67da9f16b 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -146,7 +146,8 @@ Certificate sessions cannot renew a certificate in band, and public rules 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 burst of +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 57fffcc068..c166cb2ec2 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,7 +29,7 @@ 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`. 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. 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. +- **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. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 1acef345ea..207632e0b8 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1140,12 +1140,15 @@ pub(crate) enum RequestKind { 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. + /// `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 => grant.publish.matches(path), + Self::PublishNamespace => { + crate::Pattern::subtree(path).is_ok_and(|subtree| grant.publish.contains(&subtree)) + } } } } @@ -2355,6 +2358,33 @@ mod request_token_tests { 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]