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}