Skip to main content

moqtap_proxy/
hook.rs

1//! Decide what happens to frames, objects, datagrams and streams.
2//!
3//! [`ProxyHook`] is the decision surface: every method is synchronous,
4//! defaulted, and returns *data* describing what the engine should do —
5//! never a future, never a mutation performed in place. Timing is
6//! expressed as [`Action::Delay`] / [`Action::Hold`] and executed by the
7//! egress engine, where it is precise, attributable and bounded, so a hook
8//! can never stall a read loop.
9//!
10//! [`Interest`] is the other half. It is sampled **once**, at session
11//! start, and decides which of the six methods are armed at all;
12//! [`Interest::NONE`] keeps all three forwarding paths on the zero-parse
13//! byte pump. The normative gating expression is mirrored in each method's
14//! rustdoc below.
15//!
16//! [`LegacyProxyHook`] and [`LegacyHook`] carry the 0.3.x
17//! `Option<Vec<u8>>` shape forward so existing implementations keep
18//! working while they migrate.
19
20use std::sync::Arc;
21use std::time::Instant;
22
23use bytes::Bytes;
24
25use moqtap_codec::dispatch::{AnyControlMessage, AnyDatagramHeader};
26use moqtap_codec::version::DraftVersion;
27
28use crate::action::{Action, Interest, StreamAction, StreamEnd};
29use crate::capability::Capabilities;
30use crate::event::{DataStreamHeaderKind, SessionId};
31use crate::shape::StreamKey;
32use crate::types::{ObjectMeta, ProxySide};
33
34/// Context for a control message or datagram decision.
35#[derive(Debug, Clone, Copy)]
36#[non_exhaustive]
37pub struct FrameCtx<'a> {
38    /// The session identifier.
39    pub session_id: SessionId,
40    /// The side the unit arrived on.
41    pub side: ProxySide,
42    /// The draft this unit was parsed under.
43    ///
44    /// A session's draft is not necessarily the one it was configured with.
45    /// The ALPN names a draft outright from draft-15 on, and such a session
46    /// is settled before it dials, so this never moves. Drafts 07 to 14
47    /// share the one ALPN `moq-00`: a session in that cohort starts on the
48    /// draft its configuration named and the control stream refines it from
49    /// the first SETUP it can read — CLIENT_SETUP's highest offered version,
50    /// which SERVER_SETUP's selected version then outranks, because the
51    /// second is what the peers agreed and the first is only what one of
52    /// them proposed.
53    ///
54    /// Both sites this context is built for see the refined answer rather
55    /// than the starting draft, by two different mechanisms.
56    /// [`ProxyHook::on_datagram`]'s forwarding task waits for the control
57    /// stream to answer before it reads its first datagram.
58    /// [`ProxyHook::on_control_message`] needs no wait: a session that
59    /// declared [`Interest::CONTROL`](crate::action::Interest::CONTROL)
60    /// withholds the control stream's bytes until the leading SETUP has been
61    /// peeked at, so even the frame that named the draft is reported under
62    /// it.
63    ///
64    /// Two cases end the wait with the starting draft instead, and both are
65    /// sessions no draft describes: a peer that sends no SETUP at all, and a
66    /// peer whose first control message is something else. A SETUP arriving
67    /// after either still refines what comes after it.
68    pub draft: DraftVersion,
69    /// The stream this frame belongs to. `None` for datagrams.
70    pub stream_id: Option<u64>,
71    /// When the proxy produced this unit for decision.
72    pub arrived_at: Instant,
73    /// What is executable on this draft at this site.
74    pub caps: &'a Capabilities,
75}
76
77/// Context for a stream open, stream header, or stream end decision.
78///
79/// At [`ProxyHook::on_stream_open`] this is deliberately **track-blind**:
80/// the peer stream is opened before the first byte of the source stream is
81/// read, so no track alias, group or subgroup is known. To reject a stream
82/// by track, return [`StreamAction::Open`] there and decide in
83/// [`ProxyHook::on_stream_header`], which runs before the header's bytes
84/// are forwarded.
85#[derive(Debug, Clone, Copy)]
86#[non_exhaustive]
87pub struct StreamCtx<'a> {
88    /// The session identifier.
89    pub session_id: SessionId,
90    /// The side the stream arrived on.
91    pub side: ProxySide,
92    /// The source stream's transport-level identifier.
93    ///
94    /// **`0` for every WebTransport stream** — `SendStream::stream_id()` and
95    /// `RecvStream::stream_id()` both return the constant on that arm. Use
96    /// it to correlate with the transport-level events in
97    /// [`crate::event`]; use [`Self::key`] to *identify* the stream.
98    pub stream_id: u64,
99    /// The draft this stream is being parsed under. See
100    /// [`FrameCtx::draft`] for what settles it and when.
101    ///
102    /// [`ProxyHook::on_stream_header`] always carries the settled answer:
103    /// its stream waits for one before it frames a byte, because the draft
104    /// decides where an object ends. [`ProxyHook::on_stream_open`] and
105    /// [`ProxyHook::on_stream_end`] read it without waiting — the first runs
106    /// before the stream has been read at all, so there is nothing it could
107    /// wait *for* that the session has not already asked of the control
108    /// stream — so on the `moq-00` cohort a stream opened before any SETUP
109    /// was readable is offered here under the draft the session started on.
110    pub draft: DraftVersion,
111    /// Whether the control stream's rules apply to this stream.
112    /// It is the flag that selects those rules, not a claim about which QUIC
113    /// stream this is, and on two of the three sites the difference is visible.
114    /// Read it as *a reset here is a protocol violation*, which is what every
115    /// site uses it for.
116    ///
117    /// **At [`ProxyHook::on_stream_end`] on drafts 17-19 it is `true` for a
118    /// bidirectional *request* stream as well as for the control stream.**
119    /// Those drafts carry the control plane on a pair of unidirectional
120    /// streams and carry requests on bidirectional ones (draft-17 Section
121    /// 3.3), and both are forwarded through the same control-message
122    /// framing, so both run under the control stream's end-of-stream rules
123    /// and [`Action::ResetStream`] is refused on both. Draft-17 Section
124    /// 3.3.1 permits a *request* to be cancelled by resetting its stream, so
125    /// refusing is stricter than the draft requires; it is the conservative
126    /// direction, nothing is destroyed that the draft would have kept, and
127    /// it is what [`crate::capability`] publishes. A hook that needs to tell
128    /// the two apart on those drafts cannot do it from this flag.
129    ///
130    /// **At [`ProxyHook::on_stream_open`] on drafts 17-19 it is `false` for
131    /// the unidirectional control stream.** That site runs before the
132    /// stream's type varint has been read, which is the only thing that says
133    /// what the stream is, so at that point nothing knows. One consequence
134    /// is worth stating plainly: [`StreamAction::Reject`] returned there may
135    /// land on a control stream, and no refusal reports it, because the site
136    /// had nothing to refuse it on.
137    ///
138    /// On drafts 07-16 the control stream is the first client-initiated
139    /// bidirectional stream, it never reaches `on_stream_open` at all, and
140    /// this flag means exactly what its name says at every site.
141    pub is_control_stream: bool,
142    /// What is executable on this draft at this site.
143    pub caps: &'a Capabilities,
144    /// This stream's session-local identity.
145    ///
146    /// **Private, unlike every other field on the three context types**, and
147    /// deliberately so: it is minted by the session, and a hook that could
148    /// write one could hand
149    /// [`StreamAction::SerializeAfter`](crate::action::StreamAction::SerializeAfter)
150    /// a key naming a stream that never existed. That case is defined —
151    /// it proceeds immediately and reports `SerializeTargetUnknown` — but it
152    /// is a *mistake* the engine reports, not an API the struct should
153    /// invite. [`Self::key`] is the read path and
154    /// [`Self::new`] is the write path.
155    key: StreamKey,
156}
157
158/// Context for an object decision.
159///
160/// Everything a scenario matches on is on `meta`, without re-parsing.
161/// `arrived_at` lives here and **not** on [`ObjectMeta`], which is
162/// `Copy + PartialEq + Eq` and is compared by value across the test suite.
163#[derive(Debug, Clone, Copy)]
164#[non_exhaustive]
165pub struct ObjectCtx<'a> {
166    /// The session identifier.
167    pub session_id: SessionId,
168    /// The side the object arrived on.
169    pub side: ProxySide,
170    /// The source stream's transport-level identifier.
171    pub stream_id: u64,
172    /// Identity and framing: draft, stream kind, track alias, group,
173    /// subgroup, object ID, publisher priority, index in stream, payload
174    /// length and status.
175    pub meta: &'a ObjectMeta,
176    /// When the framer produced this object.
177    pub arrived_at: Instant,
178    /// What is executable on this draft and stream kind. Consult it before
179    /// returning an action rather than discovering the refusal in a report.
180    pub caps: &'a Capabilities,
181}
182
183// ── constructors for the three context types ───────────────────────────
184//
185// All three are `#[non_exhaustive]`, which forbids struct-literal
186// construction outside this crate (E0639). Without constructors,
187// `crates/moqtap-proxy/tests/` — a separate crate — could not build a
188// `FrameCtx`, so `tests/hook_api.rs::the_hook_trait_is_dyn_compatible`
189// could not be written, and that test is the *only* guard on this trait
190// staying dyn-compatible — no `async fn`, no `async-trait`. The same
191// restriction would stop every downstream user unit-testing their own
192// hook, which is most of why these constructors exist at all.
193//
194// They are `pub`, not `#[doc(hidden)]`: unit-testing a hook is a
195// supported use, not an internal one.
196
197impl<'a> FrameCtx<'a> {
198    /// Build a context. Field order matches the struct.
199    ///
200    /// `stream_id` is `None` for datagrams.
201    pub fn new(
202        session_id: SessionId,
203        side: ProxySide,
204        draft: DraftVersion,
205        stream_id: Option<u64>,
206        arrived_at: Instant,
207        caps: &'a Capabilities,
208    ) -> Self {
209        Self { session_id, side, draft, stream_id, arrived_at, caps }
210    }
211}
212
213impl<'a> StreamCtx<'a> {
214    /// Build a context. Field order matches the struct.
215    ///
216    /// `key` is the stream's session-local identity; the session mints one
217    /// per accepted stream and hands the *same* key to all three stream
218    /// sites, which is what makes [`Self::key`] a name a later stream can
219    /// serialize behind.
220    pub fn new(
221        session_id: SessionId,
222        side: ProxySide,
223        stream_id: u64,
224        draft: DraftVersion,
225        is_control_stream: bool,
226        caps: &'a Capabilities,
227        key: StreamKey,
228    ) -> Self {
229        Self { session_id, side, stream_id, draft, is_control_stream, caps, key }
230    }
231
232    /// This stream's session-local identity, for naming it later.
233    ///
234    /// The value to hand
235    /// [`StreamAction::SerializeAfter`]
236    /// so a *different* stream waits for this one. Stable across
237    /// [`ProxyHook::on_stream_open`], [`ProxyHook::on_stream_header`] and
238    /// [`ProxyHook::on_stream_end`] for one stream, unique for the
239    /// session's lifetime, and never reused.
240    ///
241    /// **Not** [`Self::stream_id`]: that is the transport id, which is the
242    /// constant `0` on every WebTransport stream, so keying on it would
243    /// collapse every WT stream of a side onto one entry and make
244    /// `SerializeAfter` attach a stream to an arbitrary sibling — or to
245    /// itself, which is a self-deadlock that degrades to a `max_hold`
246    /// stall. See [`StreamKey`].
247    #[must_use]
248    pub fn key(&self) -> StreamKey {
249        self.key
250    }
251}
252
253impl<'a> ObjectCtx<'a> {
254    /// Build a context. Field order matches the struct.
255    pub fn new(
256        session_id: SessionId,
257        side: ProxySide,
258        stream_id: u64,
259        meta: &'a ObjectMeta,
260        arrived_at: Instant,
261        caps: &'a Capabilities,
262    ) -> Self {
263        Self { session_id, side, stream_id, meta, arrived_at, caps }
264    }
265}
266
267/// Decide what happens to frames, objects, datagrams and streams.
268///
269/// Every method is synchronous and defaulted, so `Arc<dyn ProxyHook>` stays
270/// dyn-compatible and an observing-only implementation is
271/// `impl ProxyHook for MyHook {}`. There is no `async fn` in the trait and
272/// no `async-trait` dependency. Cases that genuinely need to await an
273/// external signal use [`Action::Hold`], so a hook never stalls a read
274/// loop: timing is expressed as data and executed by the engine, where it
275/// is precise, attributable and bounded.
276pub trait ProxyHook: Send + Sync {
277    /// What this hook wants the proxy to parse.
278    ///
279    /// Sampled **once**, at session start, and cached. [`Interest::NONE`]
280    /// keeps the zero-parse byte-pump path bit-for-bit on all three
281    /// forwarding paths.
282    fn interest(&self) -> Interest {
283        Interest::NONE
284    }
285
286    /// Called before forwarding a control message.
287    ///
288    /// Fires only when [`Interest::CONTROL`] is set: without it the control
289    /// stream takes the forward-first path, where the bytes are already in
290    /// flight by the time they are parsed and a return value would be
291    /// unexecutable.
292    ///
293    /// `raw` is the frame's original wire bytes — type, scope, length
294    /// prefix and payload.
295    fn on_control_message(
296        &self,
297        _cx: &FrameCtx<'_>,
298        _msg: &AnyControlMessage,
299        _raw: &[u8],
300    ) -> Action {
301        Action::Pass
302    }
303
304    /// Called after a unidirectional stream is accepted and before the peer
305    /// stream is opened. Stream identity only — see [`StreamCtx`].
306    ///
307    /// Fires only when [`Interest::STREAMS`] is set. Under any other
308    /// interest the decision point between `accept_uni()` and
309    /// `open_uni()` is not armed and the stream is forwarded. The gate is
310    /// spelled out because otherwise it is undefined whether an
311    /// `Interest::NONE` session calls a hook method at all: it does not,
312    /// on this method or any other.
313    fn on_stream_open(&self, _cx: &StreamCtx<'_>) -> StreamAction {
314        StreamAction::Open
315    }
316
317    /// Called once the data stream's header has been framed and before its
318    /// bytes are forwarded.
319    ///
320    /// This is where track-targeted decisions belong: `header` carries the
321    /// track alias, group and publisher priority.
322    ///
323    /// Fires only when [`Interest::STREAMS`] is set. `STREAMS` includes
324    /// [`Interest::OBJECTS`] structurally, so declaring `STREAMS` alone is
325    /// sufficient and puts the stream on the framed path — which it must
326    /// be, because there is no header without framing. Declaring
327    /// `OBJECTS` alone frames the stream but does **not** call this
328    /// method.
329    fn on_stream_header(
330        &self,
331        _cx: &StreamCtx<'_>,
332        _header: &DataStreamHeaderKind,
333    ) -> StreamAction {
334        StreamAction::Open
335    }
336
337    /// Called before forwarding one complete object.
338    ///
339    /// Fires only when [`Interest::OBJECTS`] is set (and therefore also
340    /// under [`Interest::STREAMS`], which includes it). Attaching an
341    /// event observer does **not** turn this on: an observer makes the
342    /// proxy *frame* objects, so that
343    /// [`ProxyEvent::Object`](crate::event::ProxyEvent::Object) can fire,
344    /// but a hook that declared no object interest is never asked and
345    /// never has a returned `Action` honoured. Framing and consulting the
346    /// hook are separate decisions.
347    ///
348    /// `raw` is the object's exact wire bytes as they will be forwarded,
349    /// framing and payload; `cx.meta.payload_len` bytes at the end of it
350    /// are the payload. [`Action::ReplacePayload`] replaces that trailing
351    /// region.
352    /// *As they will be forwarded* is load-bearing after an elide: when a
353    /// previous object on this stream was elided on a delta-encoding draft, the
354    /// framer has already rewritten this object's leading Object ID varint, so
355    /// `raw` is what goes on the wire and `raw.len() - cx.meta.payload_len` is
356    /// still the payload offset. `cx.meta.object_id` is the absolute ID either
357    /// way.
358    ///
359    /// Not called for objects the framer could not address: an object
360    /// larger than
361    /// [`FramerConfig::max_buffered_object_bytes`](crate::framer::FramerConfig::max_buffered_object_bytes),
362    /// or any object on a stream the framer has stopped parsing. Those are
363    /// reported as
364    /// [`ProxyEvent::Impairment`](crate::event::ProxyEvent::Impairment)
365    /// instead, so "nothing matched" and "never parsed" are
366    /// distinguishable.
367    fn on_object(&self, _cx: &ObjectCtx<'_>, _raw: &[u8]) -> Action {
368        Action::Pass
369    }
370
371    /// Called before forwarding a datagram.
372    ///
373    /// Fires only when [`Interest::DATAGRAMS`] is set.
374    ///
375    /// `header` is `None` when the datagram's header did not decode. The
376    /// hook is still called, so [`Action::Drop`] on malformed traffic is
377    /// expressible. `raw` is the whole datagram.
378    ///
379    /// [`Action::ReplacePayload`] is available here only when the
380    /// payload's start is derivable: not on draft-14, whose
381    /// [`AnyDatagramHeader`] decode consumes the payload; not on a status
382    /// datagram, which has no payload slot; and not when `header` is
383    /// `None`. In those three cases it is refused with
384    /// [`Refusal::PayloadNotDelimited`](crate::capability::Refusal::PayloadNotDelimited)
385    /// and the datagram is forwarded unchanged. `cx.caps` answers this
386    /// before you ask for it.
387    fn on_datagram(
388        &self,
389        _cx: &FrameCtx<'_>,
390        _header: Option<&AnyDatagramHeader>,
391        _raw: &[u8],
392    ) -> Action {
393        Action::Pass
394    }
395
396    /// Called when a forwarded stream ends, for any reason.
397    ///
398    /// Fires when [`Interest::STREAMS`] is set, on data streams **and** on
399    /// the control stream — `cx.is_control_stream` says which rules apply,
400    /// and it must, because [`Action::ResetStream`] is illegal on a control
401    /// stream on every draft and is refused there with
402    /// [`Refusal::ControlStreamResetIllegal`](crate::capability::Refusal::ControlStreamResetIllegal).
403    /// On drafts 17-19 that flag is also `true` for a bidirectional request
404    /// stream, which runs under the same rules; see
405    /// [`StreamCtx::is_control_stream`] for why and for what it costs.
406    ///
407    /// Three actions are honoured here: [`Action::Pass`],
408    /// [`Action::ResetStream`] (data streams only) and
409    /// [`Action::CloseSession`] (both, because a close is session-scoped
410    /// and no site can be the wrong one for it — it is the documented
411    /// escalation for a control stream, where a reset is a protocol
412    /// violation). Everything else is refused with
413    /// [`Refusal::WrongSite`](crate::capability::Refusal::WrongSite). To
414    /// delay a stream's end, delay its last object.
415    fn on_stream_end(&self, _cx: &StreamCtx<'_>, _end: StreamEnd) -> Action {
416        Action::Pass
417    }
418}
419
420/// A no-op hook that passes all frames through unchanged.
421pub struct NoOpHook;
422
423impl ProxyHook for NoOpHook {}
424
425/// The 0.3.x hook shape, kept so existing implementations still compile.
426///
427/// Implement [`ProxyHook`] directly for new code; wrap an existing
428/// implementation in [`LegacyHook`] to migrate without rewriting it.
429#[deprecated(since = "0.4.0", note = "implement ProxyHook directly; wrap in LegacyHook to migrate")]
430pub trait LegacyProxyHook: Send + Sync {
431    /// Whether this hook may rewrite control messages.
432    fn wants_control_mutation(&self) -> bool {
433        false
434    }
435    /// Called before forwarding a control message.
436    fn on_control_message(
437        &self,
438        _session_id: SessionId,
439        _side: ProxySide,
440        _message: &AnyControlMessage,
441        _raw_bytes: &[u8],
442    ) -> Option<Vec<u8>> {
443        None
444    }
445    /// Called before forwarding a datagram.
446    fn on_datagram(
447        &self,
448        _session_id: SessionId,
449        _side: ProxySide,
450        _header: &AnyDatagramHeader,
451        _raw_bytes: &[u8],
452    ) -> Option<Vec<u8>> {
453        None
454    }
455}
456
457/// Adapts a [`LegacyProxyHook`] to [`ProxyHook`], preserving 0.3.x
458/// semantics exactly.
459///
460/// Three behaviours are preserved deliberately:
461///
462/// * [`Self::interest`] yields `DATAGRAMS`, plus `CONTROL` only when the
463///   wrapped hook's `wants_control_mutation()` is `true`. `DATAGRAMS` is
464///   unconditional because the 0.3.x datagram hook was the datagram hook,
465///   and dropping the flag would stop datagram rewriting for anyone who had
466///   it.
467/// * A control `Some(bytes)` returned by a hook whose
468///   `wants_control_mutation()` is `false` is **discarded**, as it was in
469///   0.3.x. Mapping it to [`Action::Replace`] unconditionally would make an
470///   observe-only hook that returns `Some(..)` out of sloppiness start
471///   rewriting production traffic — source-compatible, compiling, and
472///   semantically inverted.
473/// * `on_datagram` returns [`Action::Pass`] when the header did not decode,
474///   because 0.3.x never called the hook in that case.
475///
476/// One behaviour **changes**, and it is a change rather than a fix: in
477/// 0.3.x `on_datagram` was unreachable unless an observer was attached. It
478/// now fires regardless.
479#[allow(deprecated)]
480pub struct LegacyHook(pub Arc<dyn LegacyProxyHook>);
481
482#[allow(deprecated)]
483impl ProxyHook for LegacyHook {
484    fn interest(&self) -> Interest {
485        if self.0.wants_control_mutation() {
486            Interest::DATAGRAMS | Interest::CONTROL
487        } else {
488            Interest::DATAGRAMS
489        }
490    }
491
492    fn on_control_message(&self, cx: &FrameCtx<'_>, msg: &AnyControlMessage, raw: &[u8]) -> Action {
493        // Called unconditionally, as 0.3.x did: the 0.3.x pass-through
494        // control pipe invoked the hook for observation and threw the
495        // return away. The `wants_control_mutation()` test is on the
496        // *replacement*, not on the call.
497        let replacement = self.0.on_control_message(cx.session_id, cx.side, msg, raw);
498        match replacement {
499            Some(bytes) if self.0.wants_control_mutation() => Action::Replace(Bytes::from(bytes)),
500            _ => Action::Pass,
501        }
502    }
503
504    fn on_datagram(
505        &self,
506        cx: &FrameCtx<'_>,
507        header: Option<&AnyDatagramHeader>,
508        raw: &[u8],
509    ) -> Action {
510        // 0.3.x sat inside `if let Ok(header) = AnyDatagramHeader::decode`
511        // and was never reached for an undecodable datagram.
512        let Some(header) = header else {
513            return Action::Pass;
514        };
515        match self.0.on_datagram(cx.session_id, cx.side, header, raw) {
516            Some(bytes) => Action::Replace(Bytes::from(bytes)),
517            None => Action::Pass,
518        }
519    }
520}