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}