Skip to main content

moqtap_proxy/shape/
matcher.rs

1//! Class matching — what a [`ClassRule`](super::ClassRule) claims.
2//!
3//! Two types live here: [`RangeSet`], the hand-rolled sorted/coalesced
4//! `u64` range container the proxy uses instead of pulling in a dependency
5//! (nothing in the workspace offers one), and [`Matcher`], the AND of the
6//! eight keys a rule may name.
7//!
8//! The one rule that shapes everything below: **an absent key never
9//! matches.** A unit whose `subgroup_id` the wire did not carry does not
10//! match a `subgroup_id` matcher — it falls to the default class. `None`
11//! as wildcard was rejected outright, because it silently widens a rule
12//! aimed at video to also match audio.
13
14use std::cmp::Ordering;
15use std::ops::RangeInclusive;
16
17use moqtap_codec::dispatch::AnyDatagramMeta;
18use moqtap_codec::version::DraftVersion;
19
20use crate::types::{DataStreamType, ObjectMeta, ProxySide};
21
22/// A sorted, coalesced set of inclusive `u64` ranges.
23///
24/// Built once from a configuration and then queried per unit, so
25/// construction sorts and coalesces (including *adjacent* ranges: `1..=3`
26/// and `4..=6` become `1..=6`) and [`RangeSet::contains`] is a binary
27/// search over the result.
28///
29/// Ranges whose start exceeds their end are empty and are discarded at
30/// construction rather than stored as a range that can never match.
31///
32/// `#[non_exhaustive]` with no `Default`: the fields are private and there
33/// are two constructors, so there is no meaningful zero value to derive.
34///
35/// # The written form is a plain list
36///
37/// Under the `serde` feature a range set is written as the list of ranges
38/// it was built from — `[{"start": 1, "end": 3}, {"start": 4, "end": 6}]` —
39/// and read back **through [`RangeSet::new`]**, which is what
40/// `#[serde(from = ...)]` buys. A derived `Deserialize` would fill the private
41/// `ranges` field straight from the file, and the invariant every method here
42/// relies on — sorted, disjoint, non-adjacent — would then hold only for files
43/// that happened to be written in order. [`RangeSet::contains`] is a binary
44/// search, so on an unsorted set it does not fail: it answers `false` for
45/// values that are in the set, and the class quietly stops claiming half its
46/// traffic.
47///
48/// Two consequences of routing through the constructor are worth knowing
49/// before reading a file back. The written form is *normalised*, so the two
50/// ranges above are one range when they are read and the file that comes back
51/// out says `[{"start": 1, "end": 6}]`. And an inverted range is dropped
52/// rather than stored, so a file whose only range is `{"start": 5, "end": 1}`
53/// produces an empty set — which
54/// [`ShapeProfile::try_new`](super::ShapeProfile::try_new) then refuses as
55/// [`ShapeError::InertMatcher`](super::ShapeError::InertMatcher) rather than
56/// arming a class that can never claim anything.
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59#[cfg_attr(
60    feature = "serde",
61    serde(from = "Vec<RangeInclusive<u64>>", into = "Vec<RangeInclusive<u64>>")
62)]
63#[non_exhaustive]
64pub struct RangeSet {
65    /// Disjoint, non-adjacent, ascending by start. The invariant every
66    /// method below relies on.
67    ranges: Vec<RangeInclusive<u64>>,
68}
69
70impl RangeSet {
71    /// Build a range set from any iterator of inclusive ranges.
72    ///
73    /// The input needs no ordering: overlapping, adjacent and duplicated
74    /// ranges are merged, and empty ranges (`start > end`) are dropped.
75    pub fn new(ranges: impl IntoIterator<Item = RangeInclusive<u64>>) -> Self {
76        let mut raw: Vec<RangeInclusive<u64>> =
77            ranges.into_iter().filter(|r| r.start() <= r.end()).collect();
78        raw.sort_by_key(|r| (*r.start(), *r.end()));
79
80        let mut merged: Vec<RangeInclusive<u64>> = Vec::with_capacity(raw.len());
81        for r in raw {
82            match merged.last_mut() {
83                // `+1` is what makes `1..=3` and `4..=6` one range rather
84                // than two: they are adjacent, not overlapping.
85                // `saturating_add` so a range ending at `u64::MAX` does not
86                // panic in a debug build.
87                Some(last) if *r.start() <= last.end().saturating_add(1) => {
88                    if r.end() > last.end() {
89                        *last = *last.start()..=*r.end();
90                    }
91                }
92                _ => merged.push(r),
93            }
94        }
95        Self { ranges: merged }
96    }
97
98    /// A range set holding exactly one value.
99    pub fn single(v: u64) -> Self {
100        Self { ranges: vec![v..=v] }
101    }
102
103    /// The ranges as a plain list, which is also the written form.
104    ///
105    /// Consumes the set rather than borrowing it, because this is what
106    /// `#[serde(into = ...)]` calls and the alternative — serializing the
107    /// private field directly — would emit `{"ranges": [...]}` while the read
108    /// path expects `[...]`, so the two directions would disagree.
109    #[cfg(feature = "serde")]
110    fn into_ranges(self) -> Vec<RangeInclusive<u64>> {
111        self.ranges
112    }
113
114    /// Whether `v` falls in any of the ranges. Binary search.
115    pub fn contains(&self, v: u64) -> bool {
116        self.ranges
117            .binary_search_by(|r| {
118                if *r.end() < v {
119                    Ordering::Less
120                } else if *r.start() > v {
121                    Ordering::Greater
122                } else {
123                    Ordering::Equal
124                }
125            })
126            .is_ok()
127    }
128
129    /// Whether the set holds no values at all. Such a set matches nothing.
130    pub fn is_empty(&self) -> bool {
131        self.ranges.is_empty()
132    }
133
134    /// The coalesced ranges, ascending and disjoint.
135    ///
136    /// Exposed because coalescing is a *claim* — that `1..=3` plus `4..=6`
137    /// is one range — and [`RangeSet::contains`] cannot falsify it: both
138    /// shapes answer every `contains` query identically. A test that can
139    /// only see `contains` cannot tell a working coalescer from none.
140    pub fn ranges(&self) -> &[RangeInclusive<u64>] {
141        &self.ranges
142    }
143}
144
145/// Every read of a range set goes through [`RangeSet::new`], so the sorted,
146/// coalesced invariant holds for a set that came from a file exactly as it
147/// does for one built in Rust.
148#[cfg(feature = "serde")]
149impl From<Vec<RangeInclusive<u64>>> for RangeSet {
150    fn from(ranges: Vec<RangeInclusive<u64>>) -> Self {
151        RangeSet::new(ranges)
152    }
153}
154
155#[cfg(feature = "serde")]
156impl From<RangeSet> for Vec<RangeInclusive<u64>> {
157    fn from(set: RangeSet) -> Self {
158        set.into_ranges()
159    }
160}
161
162/// What a [`Matcher`] can be aimed at.
163///
164/// `Datagram` exists so that aiming a rule at datagrams produces a report
165/// rather than silence; datagrams are not shapeable in this release, so a
166/// `Datagram` matcher never matches a unit. The report is the scheduler's
167/// job once this module is wired.
168///
169/// `Fetch` is live on all thirteen. It was not always: drafts 18 and 19
170/// write a fetch object's Group ID as a difference whose sign the fetch's
171/// Group Order settles, and while nothing carried that order to the framer a
172/// fetch stream there was bypassed at its header and produced no
173/// [`ObjectMeta`] for a rule to see. The session reads the order off the
174/// FETCH now — `capability::fetch_group_order_is_needed` — so what is left is
175/// one stream at a time rather than a whole draft, and a stream the session
176/// cannot resolve says so itself as
177/// `Impairment { FramerBypass { FetchGroupOrderUnknown } }`.
178///
179/// Being matchable is not being *mutable*, and the two are answered
180/// separately: whether a rule claims a unit is [`Matcher::matches`], and what
181/// may then be done to it is `capability::classify`.
182///
183/// `#[non_exhaustive]` with no `Default`: there is no meaningful default
184/// stream kind, and a wrong one would silently narrow every rule that
185/// omitted the key.
186///
187/// Written as `"subgroup"`, `"fetch"` or `"datagram"`.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
191#[non_exhaustive]
192pub enum MatchKind {
193    /// A subgroup data stream.
194    Subgroup,
195    /// A fetch response data stream.
196    Fetch,
197    /// A datagram.
198    Datagram,
199}
200
201impl MatchKind {}
202
203/// One [`Matcher`] key, named so a report about it is typed rather than a
204/// string.
205///
206/// Only the keys that can be **unmatchable** are here, and that is the whole
207/// point of the type: it is carried by
208/// [`ImpairmentKind::ShapeRuleUnmatchable`](crate::event::ImpairmentKind::ShapeRuleUnmatchable),
209/// which fires when a rule keys on something this draft and stream kind
210/// cannot carry. The other four keys — `side`, `group_id`, `object_id` and
211/// `every_nth` — are present on every framed unit by construction, so a rule
212/// keyed on one of them that fails to match failed on its *value*, which is
213/// the rule working, not a rule that cannot work.
214///
215/// `#[non_exhaustive]` with no `Default`: a later draft that makes another
216/// key optional adds a variant, and no key is a meaningful zero.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
218#[non_exhaustive]
219pub enum MatcherField {
220    /// [`Matcher::track_alias`], against a unit whose stream carried none —
221    /// every fetch stream.
222    TrackAlias,
223    /// [`Matcher::subgroup_id`], against a unit whose header carried none —
224    /// eight drafts in first-object mode, and 17-19 in reserved mode 3.
225    ///
226    /// Never reported about a datagram, which carries no subgroup ID on any
227    /// draft. That is not a fact about one header, so it is a pre-run
228    /// refusal — `Capabilities::admit_class` — rather than something a run
229    /// discovers. See `Matcher::unmatchable_fields_datagram`.
230    SubgroupId,
231    /// [`Matcher::priority`], against a unit whose header set the
232    /// default-priority bit — drafts 15-19, on a subgroup object and on a
233    /// datagram alike.
234    Priority,
235}
236
237impl MatcherField {
238    /// This field's bit in a per-class report-once mask.
239    ///
240    /// A four-variant enum fits one byte, so the "reported already" state
241    /// for a whole class is one `AtomicU8` and the check is one relaxed
242    /// `fetch_or` — taken only when a field is *actually* absent, so a
243    /// profile whose keys are all carried never touches it.
244    pub(crate) const fn bit(self) -> u8 {
245        match self {
246            MatcherField::TrackAlias => 1,
247            MatcherField::SubgroupId => 2,
248            MatcherField::Priority => 4,
249        }
250    }
251}
252
253/// Which units a [`ClassRule`](super::ClassRule) claims.
254///
255/// All present fields must match (AND). An absent field matches
256/// everything. A field the wire did not carry — `None` on
257/// [`ObjectMeta`] — **does not match**.
258///
259/// `#[non_exhaustive]` *with* a [`Default`], exactly as
260/// [`EgressConfig`](crate::action::EgressConfig) is. The pairing is
261/// load-bearing: `#[non_exhaustive]` alone would make this type
262/// unconstructible from an integration-test crate or from a scenario
263/// author's code, because struct-expression *and* functional-update syntax
264/// are both illegal outside the defining crate. Note what that leaves:
265/// `..Matcher::default()` is **also** illegal there (`E0639`), so an
266/// outside caller writes `let mut m = Matcher::default();` and then assigns
267/// per field — which is what `tests/actions_shaping.rs` does. Inside this
268/// crate both forms compile, which is why the unit tests below use the
269/// shorter one. The `Default` is all-`None` — a matcher that claims every
270/// unit.
271///
272/// In the written form every key defaults to absent, so a matcher naming one
273/// field is one line, and an unknown key is refused rather than skipped — a
274/// misspelled `group_id` would otherwise widen the rule to claim every unit
275/// on the stream instead of the ten groups it named.
276#[derive(Debug, Clone, PartialEq, Eq, Default)]
277#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
278#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
279#[non_exhaustive]
280pub struct Matcher {
281    /// The direction the unit arrived on.
282    ///
283    /// Only `ClientToProxy` and `RelayToProxy` are ever seen at a hook
284    /// site; the two egress labels are used for teardown reporting. Naming
285    /// an egress side here is rejected by
286    /// [`ShapeProfile::try_new`](super::ShapeProfile::try_new) rather than
287    /// left to match nothing.
288    ///
289    /// Written as the variant's own name in kebab-case —
290    /// `"client-to-proxy"` — by a mapping in the scenario module rather than
291    /// by a derive, because [`ProxySide`] lives in a module that carries no
292    /// serde dependency of its own.
293    #[cfg_attr(feature = "serde", serde(with = "side_serde"))]
294    pub side: Option<ProxySide>,
295    /// Track alias from the stream header. `None` on every fetch stream,
296    /// so a rule keyed here never claims fetch units.
297    pub track_alias: Option<RangeSet>,
298    /// Group ID. Always present on a framed object.
299    pub group_id: Option<RangeSet>,
300    /// Subgroup ID. `None` on eight drafts in first-object mode and on
301    /// 17-19 in reserved mode 3, so a rule keyed here claims nothing there.
302    pub subgroup_id: Option<RangeSet>,
303    /// Absolute object ID. Always present on a framed object.
304    pub object_id: Option<RangeSet>,
305    /// MoQT `publisher_priority`. `None` on drafts 15-19 whenever the
306    /// header set the default-priority bit.
307    pub priority: Option<RangeInclusive<u8>>,
308    /// Which kind of stream the unit came from.
309    pub stream_kind: Option<MatchKind>,
310    /// `(n, offset)` over a counter of **hook-visible units on this
311    /// stream**, scoped **per stream and not per class**, never
312    /// [`ObjectMeta::index_in_stream`] — which counts oversized objects
313    /// that never reach the hook and would silently shift the pattern.
314    ///
315    /// Matches when `unit_index % n == offset % n`. `n == 0` names no
316    /// units, so [`ShapeProfile::try_new`](super::ShapeProfile::try_new)
317    /// rejects it as [`ShapeError::InertMatcher`](super::ShapeError::InertMatcher)
318    /// rather than accepting a class that can never claim anything.
319    pub every_nth: Option<(u64, u64)>,
320}
321
322impl Matcher {
323    /// Whether this matcher claims one unit.
324    ///
325    /// `side` is the forwarding task's own direction label — [`ObjectMeta`]
326    /// has no `side` field. `unit_index` is the per-stream count of
327    /// hook-visible units described on [`Matcher::every_nth`], supplied by
328    /// the caller for the same reason `charge` takes `now`: this function
329    /// owns no state and reads no counter, so it can be tested exhaustively
330    /// from a table.
331    pub fn matches(&self, side: ProxySide, meta: &ObjectMeta, unit_index: u64) -> bool {
332        self.claims(
333            side,
334            kind_of(meta.stream_kind),
335            Keys {
336                track_alias: meta.track_alias,
337                group_id: meta.group_id,
338                subgroup_id: meta.subgroup_id,
339                object_id: meta.object_id,
340                priority: meta.publisher_priority,
341            },
342            unit_index,
343        )
344    }
345
346    /// Whether this matcher claims one datagram.
347    ///
348    /// [`Self::matches`]'s sibling, and the two answer through one
349    /// conjunction — see the private `Keys` it is written over. What differs
350    /// is what fills that in: a
351    /// datagram states its own track alias, Group ID, Object ID and (from
352    /// draft-15, conditionally) priority, and states **no subgroup ID** on
353    /// any draft, so a rule keyed there claims no datagram. That last one is
354    /// a fact about the carrier rather than about one header, so it is
355    /// refused before the run by
356    /// [`Capabilities::admit_class`](crate::capability::Capabilities::admit_class)
357    /// rather than discovered from one.
358    ///
359    /// `unit_index` counts hook-visible datagrams **per forwarding
360    /// direction**, which is the only scope a datagram has: it belongs to no
361    /// stream, so [`Matcher::every_nth`]'s per-stream reading has nothing to
362    /// key against here and the session's two directions count separately.
363    pub fn matches_datagram(
364        &self,
365        side: ProxySide,
366        meta: &AnyDatagramMeta,
367        unit_index: u64,
368    ) -> bool {
369        self.claims(
370            side,
371            MatchKind::Datagram,
372            Keys {
373                track_alias: Some(meta.track_alias),
374                group_id: meta.group_id,
375                subgroup_id: None,
376                object_id: meta.object_id,
377                priority: meta.publisher_priority,
378            },
379            unit_index,
380        )
381    }
382
383    /// The conjunction both carriers answer through.
384    ///
385    /// Written once rather than twice because a six-key AND copied is a
386    /// sixth key forgotten: a matcher key added to this struct and to only
387    /// one of the two callers would widen every rule on the other carrier,
388    /// silently, in the direction of claiming more than it named.
389    fn claims(&self, side: ProxySide, kind: MatchKind, keys: Keys, unit_index: u64) -> bool {
390        if let Some(want) = self.side {
391            if want != side {
392                return false;
393            }
394        }
395        if let Some(want) = self.stream_kind {
396            if want != kind {
397                return false;
398            }
399        }
400        // A keyed field the wire did not carry never matches: the three
401        // `Option` keys below take the `_ => return false` arm on `None`.
402        if let Some(set) = &self.track_alias {
403            match keys.track_alias {
404                Some(v) if set.contains(v) => {}
405                _ => return false,
406            }
407        }
408        if let Some(set) = &self.group_id {
409            if !set.contains(keys.group_id) {
410                return false;
411            }
412        }
413        if let Some(set) = &self.subgroup_id {
414            match keys.subgroup_id {
415                Some(v) if set.contains(v) => {}
416                _ => return false,
417            }
418        }
419        if let Some(set) = &self.object_id {
420            if !set.contains(keys.object_id) {
421                return false;
422            }
423        }
424        if let Some(range) = &self.priority {
425            match keys.priority {
426                Some(p) if range.contains(&p) => {}
427                _ => return false,
428            }
429        }
430        if let Some((n, offset)) = self.every_nth {
431            if n == 0 || unit_index % n != offset % n {
432                return false;
433            }
434        }
435        true
436    }
437
438    /// The keys this matcher names that `meta` **cannot carry**, so a
439    /// non-match against them is a rule that can never fire rather than a
440    /// rule that did not fire.
441    ///
442    /// The distinction is the whole point of this function: `None` never
443    /// matches, and a silent fall to the default class is precisely the
444    /// failure mode this project exists to prevent. The caller reports each
445    /// `(class, field)` once per session.
446    ///
447    /// Returns a fixed-size array rather than a `Vec` — it is called on the
448    /// data path, once per rule that failed to match, and must not
449    /// allocate. `[None; 3]` is the answer for a matcher whose keys are all
450    /// carried, which is the common case.
451    ///
452    /// Every row here is a key **this unit** did not carry. There is no row
453    /// for a key no unit of this draft could ever carry, because there is no
454    /// longer such a key: the one candidate was a `Fetch`-aimed class on
455    /// drafts 18 and 19, and a fetch stream there is addressed now — see
456    /// [`MatchKind::Fetch`].
457    ///
458    /// Only checked *after* [`Self::matches`] has answered `false`: a rule
459    /// that matched cannot have been defeated by an absent key.
460    pub(crate) fn unmatchable_fields(&self, meta: &ObjectMeta) -> [Option<MatcherField>; 3] {
461        [
462            (self.track_alias.is_some() && meta.track_alias.is_none())
463                .then_some(MatcherField::TrackAlias),
464            (self.subgroup_id.is_some() && meta.subgroup_id.is_none())
465                .then_some(MatcherField::SubgroupId),
466            (self.priority.is_some() && meta.publisher_priority.is_none())
467                .then_some(MatcherField::Priority),
468        ]
469    }
470
471    /// [`Self::unmatchable_fields`]'s datagram sibling: the keys this matcher
472    /// names that **this datagram** could not carry.
473    ///
474    /// One of the three is answered here and two are deliberately not.
475    ///
476    /// * [`MatcherField::Priority`] is reported on the same terms as on a
477    ///   framed object — drafts 15 and later let a datagram's type byte set a
478    ///   default-priority bit and leave the field off, and a rule keyed on
479    ///   priority cannot claim one that did.
480    /// * [`MatcherField::TrackAlias`] never: every datagram of every draft
481    ///   states one.
482    /// * [`MatcherField::SubgroupId`] never, and this is the interesting
483    ///   one. No datagram carries a subgroup ID on any draft, so a
484    ///   *datagram-aimed* rule keyed there is refused before the session
485    ///   starts — `Capabilities::admit_class`, which is where a key a
486    ///   carrier never has belongs, because rejecting beats reporting
487    ///   wherever the answer exists without traffic. A rule that names no
488    ///   stream kind and keys on `subgroup_id` is a live subgroup rule, and
489    ///   reporting it here because a datagram went past would be a
490    ///   diagnostic about a rule that works.
491    pub(crate) fn unmatchable_fields_datagram(
492        &self,
493        draft: DraftVersion,
494        meta: &AnyDatagramMeta,
495    ) -> [Option<MatcherField>; 3] {
496        let _ = draft;
497        [
498            None,
499            None,
500            (self.priority.is_some() && meta.publisher_priority.is_none())
501                .then_some(MatcherField::Priority),
502        ]
503    }
504
505    /// The first key this matcher names that can **never** claim a unit —
506    /// not because the wire withheld it, but because the key itself names
507    /// an empty set of values.
508    /// The crate-internal `unmatchable_fields`'s sibling, and the difference is
509    /// where each is answerable: an unmatchable *field* depends on the draft and
510    /// the unit, so it can only be reported during a run, while an inert *key*
511    /// is a property of the configuration alone and is therefore
512    /// [`ShapeProfile::try_new`](super::ShapeProfile::try_new)'s to reject
513    /// before a session ever starts. Rejecting is strictly better than
514    /// reporting: a class that can never fire is the *configuration that looks
515    /// applied and does nothing* that constructor exists to prevent, and here
516    /// it is knowable without a single byte of traffic.
517    ///
518    /// Public because a caller that builds matchers from its own configuration
519    /// needs the same pre-flight refusal `try_new` gets: a matcher handed to a
520    /// [`ProxyHook`](crate::hook::ProxyHook) rather than to a shaping class
521    /// reaches no constructor that could check it.
522    ///
523    /// The three shapes, each of which `try_new` used to accept:
524    ///
525    /// - a [`RangeSet`] built from an inverted range — [`RangeSet::new`]
526    ///   drops `start > end`, so the set is empty and `contains` is always
527    ///   `false`;
528    /// - an empty [`Matcher::priority`] range (`200..=100`);
529    /// - [`Matcher::every_nth`] with `n == 0`, which
530    ///   [`Self::matches`] answers `false` for unconditionally.
531    ///
532    /// Returns the key's field name as it is spelled on this struct, so the
533    /// error message names something the author can search their own
534    /// configuration for.
535    pub fn inert_key(&self) -> Option<&'static str> {
536        let empty_set = |set: &Option<RangeSet>| set.as_ref().is_some_and(RangeSet::is_empty);
537        if empty_set(&self.track_alias) {
538            return Some("track_alias");
539        }
540        if empty_set(&self.group_id) {
541            return Some("group_id");
542        }
543        if empty_set(&self.subgroup_id) {
544            return Some("subgroup_id");
545        }
546        if empty_set(&self.object_id) {
547            return Some("object_id");
548        }
549        if self.priority.as_ref().is_some_and(RangeInclusive::is_empty) {
550            return Some("priority");
551        }
552        if matches!(self.every_nth, Some((0, _))) {
553            return Some("every_nth");
554        }
555        None
556    }
557}
558
559/// The keys one unit carries, whichever carrier it arrived on.
560///
561/// The argument to [`Matcher::claims`], and the reason a datagram and a
562/// framed object can be answered by one conjunction. Two of the five differ
563/// between the carriers and the difference is the type's whole content: a
564/// fetch object carries no track alias and a datagram carries no subgroup ID.
565struct Keys {
566    track_alias: Option<u64>,
567    group_id: u64,
568    subgroup_id: Option<u64>,
569    object_id: u64,
570    priority: Option<u8>,
571}
572
573/// Which [`MatchKind`] a framed object's stream is.
574///
575/// The `Fetch` arm is **live**, on all thirteen drafts:
576/// `detect_stream_type` maps stream type `0x05` to
577/// [`DataStreamType::Fetch`] and the framer produces ordinary [`ObjectMeta`]
578/// for it. On drafts 18 and 19 that needs the fetch's Group Order, which the
579/// session reads off the FETCH before the response opens; a response naming a
580/// request nobody made is bypassed and says so per stream, and produces no
581/// meta for a rule to be measured against either way.
582/// `the_fetch_arm_claims_a_fetch_object` is what keeps the arm from being
583/// deletable without a red.
584fn kind_of(stream_kind: DataStreamType) -> MatchKind {
585    match stream_kind {
586        DataStreamType::Subgroup => MatchKind::Subgroup,
587        DataStreamType::Fetch => MatchKind::Fetch,
588    }
589}
590
591/// The written form of [`ProxySide`](crate::event::ProxySide), which lives in
592/// a module this schema does not add derives to.
593///
594/// Four names, kebab-case, exactly the variant names. Written out here rather
595/// than derived so that the enum stays free of a serde dependency it has no
596/// other use for; the cost is that a fifth side would compile and be
597/// unwritable, which is why the mapping is exhaustive in both directions and
598/// has no wildcard arm.
599#[cfg(feature = "serde")]
600pub(crate) mod side_serde {
601    use serde::de::Error as _;
602    use serde::{Deserialize, Deserializer, Serializer};
603
604    use crate::types::ProxySide;
605
606    /// Every side, and the name it is written as.
607    const NAMES: [(ProxySide, &str); 4] = [
608        (ProxySide::ClientToProxy, "client-to-proxy"),
609        (ProxySide::ProxyToRelay, "proxy-to-relay"),
610        (ProxySide::RelayToProxy, "relay-to-proxy"),
611        (ProxySide::ProxyToClient, "proxy-to-client"),
612    ];
613
614    /// The name of one side. No wildcard arm: a fifth variant is a compile
615    /// error here rather than a side that writes itself as something else.
616    fn name(side: ProxySide) -> &'static str {
617        match side {
618            ProxySide::ClientToProxy => NAMES[0].1,
619            ProxySide::ProxyToRelay => NAMES[1].1,
620            ProxySide::RelayToProxy => NAMES[2].1,
621            ProxySide::ProxyToClient => NAMES[3].1,
622        }
623    }
624
625    /// Serialize an optional side as its name.
626    pub(crate) fn serialize<S: Serializer>(
627        side: &Option<ProxySide>,
628        serializer: S,
629    ) -> Result<S::Ok, S::Error> {
630        match side {
631            None => serializer.serialize_none(),
632            Some(side) => serializer.serialize_some(name(*side)),
633        }
634    }
635
636    /// Read an optional side, listing every accepted name if it is not one.
637    pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
638        deserializer: D,
639    ) -> Result<Option<ProxySide>, D::Error> {
640        let written = <Option<String>>::deserialize(deserializer)?;
641        let Some(written) = written else { return Ok(None) };
642        NAMES.iter().find(|(_, name)| *name == written).map(|(side, _)| Some(*side)).ok_or_else(
643            || {
644                D::Error::custom(format!(
645                    "unknown side {written:?}, expected one of {:?}",
646                    NAMES.map(|(_, name)| name)
647                ))
648            },
649        )
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use moqtap_codec::version::DraftVersion;
657
658    /// All thirteen, so a claim about "every draft" is one rather than a
659    /// sample. Nothing here decodes, so an uncompiled draft is as
660    /// answerable as a compiled one.
661    const DRAFTS: [DraftVersion; 13] = [
662        DraftVersion::Draft07,
663        DraftVersion::Draft08,
664        DraftVersion::Draft09,
665        DraftVersion::Draft10,
666        DraftVersion::Draft11,
667        DraftVersion::Draft12,
668        DraftVersion::Draft13,
669        DraftVersion::Draft14,
670        DraftVersion::Draft15,
671        DraftVersion::Draft16,
672        DraftVersion::Draft17,
673        DraftVersion::Draft18,
674        DraftVersion::Draft19,
675    ];
676
677    /// A framed subgroup object with every optional key present, so a test
678    /// can knock exactly one out and attribute the result.
679    fn meta() -> ObjectMeta {
680        ObjectMeta {
681            draft: DraftVersion::Draft19,
682            stream_kind: DataStreamType::Subgroup,
683            track_alias: Some(7),
684            group_id: 3,
685            subgroup_id: Some(4),
686            object_id: 11,
687            publisher_priority: Some(128),
688            index_in_stream: 0,
689            payload_len: 16,
690            status: None,
691            end_of_range: None,
692        }
693    }
694
695    /// Coalescing, boundaries and the empty set.
696    ///
697    /// The `ranges()` assertions are the load-bearing half: `contains`
698    /// alone cannot tell `[1..=3, 4..=6]` from `[1..=6]`.
699    #[test]
700    fn a_range_set_contains_only_its_ranges() {
701        // Empty.
702        let empty = RangeSet::new([]);
703        assert!(empty.is_empty());
704        assert!(!empty.contains(0));
705        assert!(!empty.contains(u64::MAX));
706
707        // Single.
708        let one = RangeSet::single(7);
709        assert_eq!(one.ranges(), &[7..=7]);
710        assert!(one.contains(7));
711        assert!(!one.contains(6));
712        assert!(!one.contains(8));
713
714        // Unsorted input is sorted; disjoint ranges stay disjoint.
715        let two = RangeSet::new([5..=9, 1..=3]);
716        assert_eq!(two.ranges(), &[1..=3, 5..=9]);
717        for v in [1, 2, 3, 5, 6, 9] {
718            assert!(two.contains(v), "should contain {v}");
719        }
720        for v in [0, 4, 10] {
721            assert!(!two.contains(v), "should not contain {v}");
722        }
723
724        // Adjacency coalesces. This is the `+1`.
725        assert_eq!(RangeSet::new([1..=3, 4..=6]).ranges(), &[1..=6]);
726        // Overlap coalesces, and the wider end wins.
727        assert_eq!(RangeSet::new([1..=5, 3..=9]).ranges(), &[1..=9]);
728        // A range wholly inside another is absorbed, not appended.
729        assert_eq!(RangeSet::new([1..=9, 3..=5]).ranges(), &[1..=9]);
730        // A one-value gap is NOT adjacency.
731        assert_eq!(RangeSet::new([1..=3, 5..=6]).ranges(), &[1..=3, 5..=6]);
732
733        // Empty ranges are dropped rather than stored. Bound through
734        // locals: a literal `9..=1` is a clippy error at the call site,
735        // which is exactly the case a configuration built at runtime can
736        // still produce.
737        let (lo, hi) = (9u64, 1u64);
738        assert!(RangeSet::new([lo..=hi]).is_empty());
739        assert_eq!(RangeSet::new([lo..=hi, 2..=4]).ranges(), &[2..=4]);
740
741        // The top of the space does not panic and does not wrap.
742        let top = RangeSet::new([u64::MAX..=u64::MAX]);
743        assert!(top.contains(u64::MAX));
744        assert!(!top.contains(u64::MAX - 1));
745    }
746
747    /// An absent key does not match — `None` is never a wildcard.
748    #[test]
749    fn an_absent_key_does_not_match() {
750        let keyed = Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() };
751
752        // Positive control: the key is present and in range.
753        assert!(keyed.matches(ProxySide::ClientToProxy, &meta(), 0));
754
755        // The wire did not carry a subgroup ID: no match, and the value the
756        // codec would have stored (zero) is not consulted.
757        let absent = ObjectMeta { subgroup_id: None, ..meta() };
758        assert!(!keyed.matches(ProxySide::ClientToProxy, &absent, 0));
759
760        // Present but out of range: also no match, so the assertion above
761        // is about absence and not about the range.
762        let other = ObjectMeta { subgroup_id: Some(5), ..meta() };
763        assert!(!keyed.matches(ProxySide::ClientToProxy, &other, 0));
764
765        // The same rule on the other two `Option` keys.
766        let by_alias = Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() };
767        assert!(by_alias.matches(ProxySide::ClientToProxy, &meta(), 0));
768        let fetch = ObjectMeta { track_alias: None, ..meta() };
769        assert!(!by_alias.matches(ProxySide::ClientToProxy, &fetch, 0));
770
771        let by_priority = Matcher { priority: Some(0..=200), ..Matcher::default() };
772        assert!(by_priority.matches(ProxySide::ClientToProxy, &meta(), 0));
773        let defaulted = ObjectMeta { publisher_priority: None, ..meta() };
774        assert!(!by_priority.matches(ProxySide::ClientToProxy, &defaulted, 0));
775
776        // An all-absent matcher still claims everything, so "None never
777        // matches" is about a *keyed* field and not about the matcher.
778        let any = Matcher::default();
779        assert!(any.matches(ProxySide::ClientToProxy, &absent, 0));
780        assert!(any.matches(ProxySide::RelayToProxy, &fetch, 9));
781    }
782
783    /// The remaining matcher keys, so `matches` is not gated by
784    /// `an_absent_key_does_not_match` alone.
785    #[test]
786    fn the_other_keys_and_are_ends_a_match() {
787        let m = Matcher {
788            side: Some(ProxySide::RelayToProxy),
789            group_id: Some(RangeSet::new([0..=3])),
790            object_id: Some(RangeSet::new([10..=12])),
791            stream_kind: Some(MatchKind::Subgroup),
792            every_nth: Some((3, 1)),
793            ..Matcher::default()
794        };
795        assert!(m.matches(ProxySide::RelayToProxy, &meta(), 4));
796
797        // Each key alone flips the AND to false.
798        assert!(!m.matches(ProxySide::ClientToProxy, &meta(), 4));
799        assert!(!m.matches(ProxySide::RelayToProxy, &ObjectMeta { group_id: 4, ..meta() }, 4));
800        assert!(!m.matches(ProxySide::RelayToProxy, &ObjectMeta { object_id: 13, ..meta() }, 4));
801        let fetch = ObjectMeta { stream_kind: DataStreamType::Fetch, ..meta() };
802        assert!(!m.matches(ProxySide::RelayToProxy, &fetch, 4));
803        assert!(!m.matches(ProxySide::RelayToProxy, &meta(), 5));
804
805        // A datagram rule never claims a framed object, on either kind.
806        let dgram = Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() };
807        assert!(!dgram.matches(ProxySide::ClientToProxy, &meta(), 0));
808        assert!(!dgram.matches(ProxySide::ClientToProxy, &fetch, 0));
809
810        // `n == 0` names no units.
811        let never = Matcher { every_nth: Some((0, 0)), ..Matcher::default() };
812        assert!(!never.matches(ProxySide::ClientToProxy, &meta(), 0));
813    }
814
815    /// **A `Fetch`-aimed rule claims a fetch object.** The positive half of
816    /// `kind_matches`, which nothing exercised: `the_other_keys_and_ends_a_match`
817    /// only asserts that a *Subgroup* rule rejects a fetch meta, and that
818    /// stays green with the `Fetch` arm deleted.
819    ///
820    /// The three assertions are the three things one arm has to get right:
821    /// the kind it names matches, the *other* stream kind does not, and the
822    /// `Datagram` kind matches neither — so a `kind_matches` rewritten as
823    /// `want != Datagram` would still redden.
824    ///
825    /// *Ablation, recorded:* delete `| (MatchKind::Fetch,
826    /// DataStreamType::Fetch)` from `kind_matches`. Before this test the
827    /// whole crate stayed green at 392 passed / 0 failed / 0 filtered out —
828    /// a matcher arm with no gate at all. With it:
829    ///
830    /// ```text
831    /// thread '...the_fetch_arm_claims_a_fetch_object' panicked at
832    /// crates\moqtap-proxy\src\shape\matcher.rs:597:9:
833    /// a Fetch-aimed class must claim a fetch object: the arm is live on the eight
834    /// drafts that have a fetch object codec
835    /// ```
836    #[test]
837    fn the_fetch_arm_claims_a_fetch_object() {
838        let fetch = ObjectMeta {
839            draft: DraftVersion::Draft14,
840            stream_kind: DataStreamType::Fetch,
841            track_alias: None,
842            ..meta()
843        };
844        let fetch_rule = Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() };
845
846        assert!(
847            fetch_rule.matches(ProxySide::ClientToProxy, &fetch, 0),
848            "a Fetch-aimed class must claim a fetch object: the arm is live on \
849             the eight drafts that have a fetch object codec"
850        );
851        assert!(
852            !fetch_rule.matches(ProxySide::ClientToProxy, &meta(), 0),
853            "and must not claim a subgroup object, or `stream_kind` is not a key"
854        );
855        let dgram_rule = Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() };
856        assert!(!dgram_rule.matches(ProxySide::ClientToProxy, &fetch, 0));
857    }
858
859    /// **Naming a stream kind never makes a rule unmatchable**, on any draft
860    /// and from either carrier.
861    ///
862    /// One pair used to be the exception: a `Fetch`-aimed class on drafts 18
863    /// and 19, where every fetch stream was bypassed at its header for want
864    /// of a Group Order and no `ObjectMeta` was ever built for the rule to
865    /// see. The session carries that order now, so the exception is gone and
866    /// with it the whole idea that a *kind* can be dead on a *draft* — what
867    /// is left is one unresolvable stream at a time, which reports itself as
868    /// `Impairment { FramerBypass { FetchGroupOrderUnknown } }`.
869    ///
870    /// The contrast is what keeps this from being a test that nothing can
871    /// fail: a key a fetch unit really cannot carry is still reported, on the
872    /// same drafts, through the same call. Every fetch header carries a
873    /// Request ID where a subgroup header carries a Track Alias, so a rule
874    /// keyed on the alias can never claim a fetch object on any draft — and
875    /// that is a fact about the *key*, which is the only kind of fact this
876    /// function still states.
877    ///
878    /// *Ablation:* drop the `TrackAlias` row from
879    /// [`Matcher::unmatchable_fields`]:
880    ///
881    /// ```text
882    /// assertion `left == right` failed: a fetch header carries a request id
883    /// where a subgroup header carries an alias, on every draft
884    ///   left: None
885    ///  right: Some(TrackAlias)
886    /// ```
887    #[test]
888    fn naming_a_stream_kind_never_makes_a_rule_unmatchable() {
889        let fetch_unit = |draft| ObjectMeta {
890            draft,
891            stream_kind: DataStreamType::Fetch,
892            track_alias: None,
893            ..meta()
894        };
895        let field = |m: &Matcher, unit: &ObjectMeta| {
896            m.unmatchable_fields(unit).into_iter().flatten().last()
897        };
898
899        for kind in [MatchKind::Subgroup, MatchKind::Fetch, MatchKind::Datagram] {
900            let rule = Matcher { stream_kind: Some(kind), ..Matcher::default() };
901            for draft in DRAFTS {
902                assert_eq!(
903                    field(&rule, &ObjectMeta { draft, ..meta() }),
904                    None,
905                    "{draft:?}: a rule aimed at {kind:?} failed to match a subgroup unit, \
906                     which is a rule working rather than a rule that cannot work"
907                );
908                assert_eq!(
909                    field(&rule, &fetch_unit(draft)),
910                    None,
911                    "{draft:?}: nor against a fetch unit"
912                );
913            }
914        }
915
916        // The contrast, on every draft: a key the carrier withholds.
917        let by_alias = Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() };
918        for draft in DRAFTS {
919            assert_eq!(
920                field(&by_alias, &fetch_unit(draft)),
921                Some(MatcherField::TrackAlias),
922                "a fetch header carries a request id where a subgroup header carries an \
923                 alias, on every draft"
924            );
925        }
926
927        // And from the datagram carrier, where the kind row also went: a
928        // datagram states its own alias and priority, so a rule aimed at any
929        // kind reports nothing about one.
930        let dgram = AnyDatagramMeta {
931            track_alias: 7,
932            group_id: 3,
933            object_id: 11,
934            publisher_priority: Some(128),
935            status: None,
936        };
937        for kind in [MatchKind::Subgroup, MatchKind::Fetch, MatchKind::Datagram] {
938            let rule = Matcher { stream_kind: Some(kind), ..Matcher::default() };
939            for draft in DRAFTS {
940                assert_eq!(
941                    rule.unmatchable_fields_datagram(draft, &dgram).into_iter().flatten().last(),
942                    None,
943                    "{draft:?}: the answer must not depend on which carrier asked"
944                );
945            }
946        }
947    }
948
949    /// **Every key that can name an empty set of values is detected**, one
950    /// row per key, so `ShapeProfile::try_new` can reject the class rather
951    /// than ship one that looks applied and claims nothing.
952    ///
953    /// The positive control on each row is the same key holding a
954    /// *non-empty* value: without it the table would pass against an
955    /// `inert_key` that answered `Some` for any key that was set at all,
956    /// which would reject every working profile.
957    ///
958    /// *Ablation, recorded:* drop the `every_nth` arm — the `n == 0` row
959    /// reddens with `left: None / right: Some("every_nth")`.
960    #[test]
961    fn an_empty_value_set_is_an_inert_key() {
962        // A literal `9..=1` is a clippy error at the call site; a runtime
963        // configuration can still produce one, which is the whole case.
964        let (lo, hi) = (9u64, 1u64);
965        let inverted = || RangeSet::new([lo..=hi]);
966        let ok = || RangeSet::single(1);
967
968        let (top, bottom) = (200u8, 100u8);
969        // `&dyn Fn` and not a `fn` pointer: the rows close over the locals
970        // above, which is what keeps a reversed literal out of the source.
971        type Edit<'a> = &'a dyn Fn(&mut Matcher, bool);
972        let rows: [(&str, Edit); 6] = [
973            ("track_alias", &|m, bad| m.track_alias = Some(if bad { inverted() } else { ok() })),
974            ("group_id", &|m, bad| m.group_id = Some(if bad { inverted() } else { ok() })),
975            ("subgroup_id", &|m, bad| m.subgroup_id = Some(if bad { inverted() } else { ok() })),
976            ("object_id", &|m, bad| m.object_id = Some(if bad { inverted() } else { ok() })),
977            ("priority", &|m, bad| {
978                m.priority = Some(if bad { top..=bottom } else { bottom..=top });
979            }),
980            ("every_nth", &|m, bad| m.every_nth = Some((if bad { 0 } else { 2 }, 0))),
981        ];
982
983        for (key, edit) in rows {
984            let mut inert = Matcher::default();
985            edit(&mut inert, true);
986            assert_eq!(inert.inert_key(), Some(key), "{key} names no value at all");
987
988            let mut live = Matcher::default();
989            edit(&mut live, false);
990            assert_eq!(live.inert_key(), None, "{key} holding a real value is a working rule");
991        }
992
993        // A matcher that keys on nothing claims everything, which is not
994        // inert — it is the default.
995        assert_eq!(Matcher::default().inert_key(), None);
996    }
997
998    /// Every side has a written form, and an unknown one is refused with the
999    /// four names listed.
1000    #[cfg(feature = "serde")]
1001    #[test]
1002    fn sides_round_trip_by_name() {
1003        #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
1004        struct Holder {
1005            #[serde(with = "super::side_serde")]
1006            side: Option<crate::types::ProxySide>,
1007        }
1008
1009        let holder = Holder { side: Some(crate::types::ProxySide::RelayToProxy) };
1010        let json = serde_json::to_string(&holder).expect("serializes");
1011        assert_eq!(json, r#"{"side":"relay-to-proxy"}"#);
1012        assert_eq!(serde_json::from_str::<Holder>(&json).expect("reads back"), holder);
1013
1014        let refusal = serde_json::from_str::<Holder>(r#"{"side":"relay->proxy"}"#)
1015            .expect_err("an unknown side is refused");
1016        assert!(
1017            refusal.to_string().contains("relay-to-proxy"),
1018            "the refusal lists the accepted names: {refusal}"
1019        );
1020    }
1021}