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