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 every draft. Drafts 18, 19 and 20 write a fetch
170/// object's Group ID as a difference whose sign the fetch's Group Order
171/// settles; the session reads that order off the FETCH —
172/// `capability::fetch_group_order_is_needed` — and hands it to the framer, so
173/// what a rule can miss is one stream at a time rather than a whole draft. A
174/// stream the session cannot resolve is bypassed at its header and produces
175/// no [`ObjectMeta`] for a rule to see, and says so itself as
176/// `Impairment { FramerBypass { FetchGroupOrderUnknown } }`.
177///
178/// Being matchable is not being *mutable*, and the two are answered
179/// separately: whether a rule claims a unit is [`Matcher::matches`], and what
180/// may then be done to it is `capability::classify`.
181///
182/// `#[non_exhaustive]` with no `Default`: there is no meaningful default
183/// stream kind, and a wrong one would silently narrow every rule that
184/// omitted the key.
185///
186/// Written as `"subgroup"`, `"fetch"` or `"datagram"`.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
189#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
190#[non_exhaustive]
191pub enum MatchKind {
192    /// A subgroup data stream.
193    Subgroup,
194    /// A fetch response data stream.
195    Fetch,
196    /// A datagram.
197    Datagram,
198}
199
200impl MatchKind {}
201
202/// One [`Matcher`] key, named so a report about it is typed rather than a
203/// string.
204///
205/// Only the keys that can be **unmatchable** are here, and that is the whole
206/// point of the type: it is carried by
207/// [`ImpairmentKind::ShapeRuleUnmatchable`](crate::event::ImpairmentKind::ShapeRuleUnmatchable),
208/// which fires when a rule keys on something this draft and stream kind
209/// cannot carry. The other four keys — `side`, `group_id`, `object_id` and
210/// `every_nth` — are present on every framed unit by construction, so a rule
211/// keyed on one of them that fails to match failed on its *value*, which is
212/// the rule working, not a rule that cannot work.
213///
214/// `#[non_exhaustive]` with no `Default`: a later draft that makes another
215/// key optional adds a variant, and no key is a meaningful zero.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
217#[non_exhaustive]
218pub enum MatcherField {
219    /// [`Matcher::track_alias`], against a unit whose stream carried none —
220    /// every fetch stream.
221    TrackAlias,
222    /// [`Matcher::subgroup_id`], against a unit whose header carried none —
223    /// ten drafts in first-object mode, and 16-21 in reserved mode 3.
224    ///
225    /// Never reported about a datagram, which carries no subgroup ID on any
226    /// draft. That is not a fact about one header, so it is a pre-run
227    /// refusal — `Capabilities::admit_class` — rather than something a run
228    /// discovers. See `Matcher::unmatchable_fields_datagram`.
229    SubgroupId,
230    /// [`Matcher::priority`], against a unit whose header set the
231    /// default-priority bit — drafts 15-19, on a subgroup object and on a
232    /// datagram alike.
233    Priority,
234}
235
236impl MatcherField {
237    /// This field's bit in a per-class report-once mask.
238    ///
239    /// A four-variant enum fits one byte, so the "reported already" state
240    /// for a whole class is one `AtomicU8` and the check is one relaxed
241    /// `fetch_or` — taken only when a field is *actually* absent, so a
242    /// profile whose keys are all carried never touches it.
243    pub(crate) const fn bit(self) -> u8 {
244        match self {
245            MatcherField::TrackAlias => 1,
246            MatcherField::SubgroupId => 2,
247            MatcherField::Priority => 4,
248        }
249    }
250}
251
252/// Which units a [`ClassRule`](super::ClassRule) claims.
253///
254/// All present fields must match (AND). An absent field matches
255/// everything. A field the wire did not carry — `None` on
256/// [`ObjectMeta`] — **does not match**.
257///
258/// `#[non_exhaustive]` *with* a [`Default`], exactly as
259/// [`EgressConfig`](crate::action::EgressConfig) is. The pairing is
260/// load-bearing: `#[non_exhaustive]` alone would make this type
261/// unconstructible from an integration-test crate or from a caller's
262/// code, because struct-expression *and* functional-update syntax
263/// are both illegal outside the defining crate. Note what that leaves:
264/// `..Matcher::default()` is **also** illegal there (`E0639`), so an
265/// outside caller writes `let mut m = Matcher::default();` and then assigns
266/// per field — which is what `tests/actions_shaping.rs` does. Inside this
267/// crate both forms compile, which is why the unit tests below use the
268/// shorter one. The `Default` is all-`None` — a matcher that claims every
269/// unit.
270///
271/// In the written form every key defaults to absent, so a matcher naming one
272/// field is one line, and an unknown key is refused rather than skipped — a
273/// misspelled `group_id` would otherwise widen the rule to claim every unit
274/// on the stream instead of the ten groups it named.
275#[derive(Debug, Clone, PartialEq, Eq, Default)]
276#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
277#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
278#[non_exhaustive]
279pub struct Matcher {
280    /// The direction the unit arrived on.
281    ///
282    /// Only `ClientToProxy` and `RelayToProxy` are ever seen at a hook
283    /// site; the two egress labels are used for teardown reporting. Naming
284    /// an egress side here is rejected by
285    /// [`ShapeProfile::try_new`](super::ShapeProfile::try_new) rather than
286    /// left to match nothing.
287    ///
288    /// Written as the variant's own name in kebab-case —
289    /// `"client-to-proxy"` — by the `side_serde` mapping below rather than
290    /// by a derive, because [`ProxySide`] lives in a module that carries no
291    /// serde dependency of its own.
292    #[cfg_attr(feature = "serde", serde(with = "side_serde"))]
293    pub side: Option<ProxySide>,
294    /// Track alias from the stream header. `None` on every fetch stream,
295    /// so a rule keyed here never claims fetch units.
296    pub track_alias: Option<RangeSet>,
297    /// Group ID. Always present on a framed object.
298    pub group_id: Option<RangeSet>,
299    /// Subgroup ID. `None` on ten drafts in first-object mode and on
300    /// 16-20 in reserved mode 3, so a rule keyed here claims nothing there.
301    pub subgroup_id: Option<RangeSet>,
302    /// Absolute object ID. Always present on a framed object.
303    pub object_id: Option<RangeSet>,
304    /// MoQT `publisher_priority`. `None` on drafts 15-19 whenever the
305    /// header set the default-priority bit.
306    pub priority: Option<RangeInclusive<u8>>,
307    /// Which kind of stream the unit came from.
308    pub stream_kind: Option<MatchKind>,
309    /// `(n, offset)` over a counter of **hook-visible units on this
310    /// stream**, scoped **per stream and not per class**, never
311    /// [`ObjectMeta::index_in_stream`] — which counts oversized objects
312    /// that never reach the hook and would silently shift the pattern.
313    ///
314    /// Matches when `unit_index % n == offset % n`. `n == 0` names no
315    /// units, so [`ShapeProfile::try_new`](super::ShapeProfile::try_new)
316    /// rejects it as [`ShapeError::InertMatcher`](super::ShapeError::InertMatcher)
317    /// rather than accepting a class that can never claim anything.
318    pub every_nth: Option<(u64, u64)>,
319}
320
321impl Matcher {
322    /// Whether this matcher claims one unit.
323    ///
324    /// `side` is the forwarding task's own direction label — [`ObjectMeta`]
325    /// has no `side` field. `unit_index` is the per-stream count of
326    /// hook-visible units described on [`Matcher::every_nth`], supplied by
327    /// the caller for the same reason `charge` takes `now`: this function
328    /// owns no state and reads no counter, so it can be tested exhaustively
329    /// from a table.
330    pub fn matches(&self, side: ProxySide, meta: &ObjectMeta, unit_index: u64) -> bool {
331        self.claims(
332            side,
333            kind_of(meta.stream_kind),
334            Keys {
335                track_alias: meta.track_alias,
336                group_id: meta.group_id,
337                subgroup_id: meta.subgroup_id,
338                object_id: meta.object_id,
339                priority: meta.publisher_priority,
340            },
341            unit_index,
342        )
343    }
344
345    /// Whether this matcher claims one datagram.
346    ///
347    /// [`Self::matches`]'s sibling, and the two answer through one
348    /// conjunction — see the private `Keys` it is written over. What differs
349    /// is what fills that in: a
350    /// datagram states its own track alias, Group ID, Object ID and (from
351    /// draft-15, conditionally) priority, and states **no subgroup ID** on
352    /// any draft, so a rule keyed there claims no datagram. That last one is
353    /// a fact about the carrier rather than about one header, so it is
354    /// refused before the run by
355    /// [`Capabilities::admit_class`](crate::capability::Capabilities::admit_class)
356    /// rather than discovered from one.
357    ///
358    /// `unit_index` counts hook-visible datagrams **per forwarding
359    /// direction**, which is the only scope a datagram has: it belongs to no
360    /// stream, so [`Matcher::every_nth`]'s per-stream reading has nothing to
361    /// key against here and the session's two directions count separately.
362    pub fn matches_datagram(
363        &self,
364        side: ProxySide,
365        meta: &AnyDatagramMeta,
366        unit_index: u64,
367    ) -> bool {
368        self.claims(
369            side,
370            MatchKind::Datagram,
371            Keys {
372                track_alias: Some(meta.track_alias),
373                group_id: meta.group_id,
374                subgroup_id: None,
375                object_id: meta.object_id,
376                priority: meta.publisher_priority,
377            },
378            unit_index,
379        )
380    }
381
382    /// The conjunction both carriers answer through.
383    ///
384    /// Written once rather than twice because a six-key AND copied is a
385    /// sixth key forgotten: a matcher key added to this struct and to only
386    /// one of the two callers would widen every rule on the other carrier,
387    /// silently, in the direction of claiming more than it named.
388    fn claims(&self, side: ProxySide, kind: MatchKind, keys: Keys, unit_index: u64) -> bool {
389        if let Some(want) = self.side {
390            if want != side {
391                return false;
392            }
393        }
394        if let Some(want) = self.stream_kind {
395            if want != kind {
396                return false;
397            }
398        }
399        // A keyed field the wire did not carry never matches: the three
400        // `Option` keys below take the `_ => return false` arm on `None`.
401        if let Some(set) = &self.track_alias {
402            match keys.track_alias {
403                Some(v) if set.contains(v) => {}
404                _ => return false,
405            }
406        }
407        if let Some(set) = &self.group_id {
408            if !set.contains(keys.group_id) {
409                return false;
410            }
411        }
412        if let Some(set) = &self.subgroup_id {
413            match keys.subgroup_id {
414                Some(v) if set.contains(v) => {}
415                _ => return false,
416            }
417        }
418        if let Some(set) = &self.object_id {
419            if !set.contains(keys.object_id) {
420                return false;
421            }
422        }
423        if let Some(range) = &self.priority {
424            match keys.priority {
425                Some(p) if range.contains(&p) => {}
426                _ => return false,
427            }
428        }
429        if let Some((n, offset)) = self.every_nth {
430            if n == 0 || unit_index % n != offset % n {
431                return false;
432            }
433        }
434        true
435    }
436
437    /// The keys this matcher names that `meta` **cannot carry**, so a
438    /// non-match against them is a rule that can never fire rather than a
439    /// rule that did not fire.
440    ///
441    /// The distinction is the whole point of this function: `None` never
442    /// matches, and a silent fall to the default class is precisely the
443    /// failure mode this project exists to prevent. The caller reports each
444    /// `(class, field)` once per session.
445    ///
446    /// Returns a fixed-size array rather than a `Vec` — it is called on the
447    /// data path, once per rule that failed to match, and must not
448    /// allocate. `[None; 3]` is the answer for a matcher whose keys are all
449    /// carried, which is the common case.
450    ///
451    /// Every row here is a key **this unit** did not carry, and a unit is the
452    /// only scope this function answers at. No row says a key is dead for a
453    /// whole draft: a `Fetch`-aimed class is matchable on every draft, and a
454    /// fetch stream whose Group Order the session cannot resolve is bypassed
455    /// at its header and produces no [`ObjectMeta`] for a rule to be measured
456    /// against — see [`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 this catches:
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 the 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, 19 and 20 that needs the fetch's Group Order, which
579/// the session reads off the FETCH before the response opens; a response
580/// naming a request nobody made is bypassed and says so per stream, and
581/// produces no 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    /// Every draft, 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    ///
662    /// Taken from [`DraftVersion::ALL`] rather than transcribed. A sweep whose
663    /// draft list is written out can quietly cover one draft fewer than it
664    /// claims to, and that is not a failing test, it is a smaller one.
665    const DRAFTS: [DraftVersion; DraftVersion::ALL.len()] = DraftVersion::ALL;
666
667    /// A framed subgroup object with every optional key present, so a test
668    /// can knock exactly one out and attribute the result.
669    fn meta() -> ObjectMeta {
670        ObjectMeta {
671            draft: DraftVersion::Draft19,
672            stream_kind: DataStreamType::Subgroup,
673            track_alias: Some(7),
674            group_id: 3,
675            subgroup_id: Some(4),
676            object_id: 11,
677            publisher_priority: Some(128),
678            index_in_stream: 0,
679            payload_len: 16,
680            status: None,
681            end_of_range: None,
682        }
683    }
684
685    /// Coalescing, boundaries and the empty set.
686    ///
687    /// The `ranges()` assertions are the load-bearing half: `contains`
688    /// alone cannot tell `[1..=3, 4..=6]` from `[1..=6]`.
689    #[test]
690    fn a_range_set_contains_only_its_ranges() {
691        // Empty.
692        let empty = RangeSet::new([]);
693        assert!(empty.is_empty());
694        assert!(!empty.contains(0));
695        assert!(!empty.contains(u64::MAX));
696
697        // Single.
698        let one = RangeSet::single(7);
699        assert_eq!(one.ranges(), &[7..=7]);
700        assert!(one.contains(7));
701        assert!(!one.contains(6));
702        assert!(!one.contains(8));
703
704        // Unsorted input is sorted; disjoint ranges stay disjoint.
705        let two = RangeSet::new([5..=9, 1..=3]);
706        assert_eq!(two.ranges(), &[1..=3, 5..=9]);
707        for v in [1, 2, 3, 5, 6, 9] {
708            assert!(two.contains(v), "should contain {v}");
709        }
710        for v in [0, 4, 10] {
711            assert!(!two.contains(v), "should not contain {v}");
712        }
713
714        // Adjacency coalesces. This is the `+1`.
715        assert_eq!(RangeSet::new([1..=3, 4..=6]).ranges(), &[1..=6]);
716        // Overlap coalesces, and the wider end wins.
717        assert_eq!(RangeSet::new([1..=5, 3..=9]).ranges(), &[1..=9]);
718        // A range wholly inside another is absorbed, not appended.
719        assert_eq!(RangeSet::new([1..=9, 3..=5]).ranges(), &[1..=9]);
720        // A one-value gap is NOT adjacency.
721        assert_eq!(RangeSet::new([1..=3, 5..=6]).ranges(), &[1..=3, 5..=6]);
722
723        // Empty ranges are dropped rather than stored. Bound through
724        // locals: a literal `9..=1` is a clippy error at the call site,
725        // which is exactly the case a configuration built at runtime can
726        // still produce.
727        let (lo, hi) = (9u64, 1u64);
728        assert!(RangeSet::new([lo..=hi]).is_empty());
729        assert_eq!(RangeSet::new([lo..=hi, 2..=4]).ranges(), &[2..=4]);
730
731        // The top of the space does not panic and does not wrap.
732        let top = RangeSet::new([u64::MAX..=u64::MAX]);
733        assert!(top.contains(u64::MAX));
734        assert!(!top.contains(u64::MAX - 1));
735    }
736
737    /// An absent key does not match — `None` is never a wildcard.
738    #[test]
739    fn an_absent_key_does_not_match() {
740        let keyed = Matcher { subgroup_id: Some(RangeSet::single(4)), ..Matcher::default() };
741
742        // Positive control: the key is present and in range.
743        assert!(keyed.matches(ProxySide::ClientToProxy, &meta(), 0));
744
745        // The wire did not carry a subgroup ID: no match, and the value the
746        // codec would have stored (zero) is not consulted.
747        let absent = ObjectMeta { subgroup_id: None, ..meta() };
748        assert!(!keyed.matches(ProxySide::ClientToProxy, &absent, 0));
749
750        // Present but out of range: also no match, so the assertion above
751        // is about absence and not about the range.
752        let other = ObjectMeta { subgroup_id: Some(5), ..meta() };
753        assert!(!keyed.matches(ProxySide::ClientToProxy, &other, 0));
754
755        // The same rule on the other two `Option` keys.
756        let by_alias = Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() };
757        assert!(by_alias.matches(ProxySide::ClientToProxy, &meta(), 0));
758        let fetch = ObjectMeta { track_alias: None, ..meta() };
759        assert!(!by_alias.matches(ProxySide::ClientToProxy, &fetch, 0));
760
761        let by_priority = Matcher { priority: Some(0..=200), ..Matcher::default() };
762        assert!(by_priority.matches(ProxySide::ClientToProxy, &meta(), 0));
763        let defaulted = ObjectMeta { publisher_priority: None, ..meta() };
764        assert!(!by_priority.matches(ProxySide::ClientToProxy, &defaulted, 0));
765
766        // An all-absent matcher still claims everything, so "None never
767        // matches" is about a *keyed* field and not about the matcher.
768        let any = Matcher::default();
769        assert!(any.matches(ProxySide::ClientToProxy, &absent, 0));
770        assert!(any.matches(ProxySide::RelayToProxy, &fetch, 9));
771    }
772
773    /// The remaining matcher keys, so `matches` is not gated by
774    /// `an_absent_key_does_not_match` alone.
775    #[test]
776    fn the_other_keys_and_are_ends_a_match() {
777        let m = Matcher {
778            side: Some(ProxySide::RelayToProxy),
779            group_id: Some(RangeSet::new([0..=3])),
780            object_id: Some(RangeSet::new([10..=12])),
781            stream_kind: Some(MatchKind::Subgroup),
782            every_nth: Some((3, 1)),
783            ..Matcher::default()
784        };
785        assert!(m.matches(ProxySide::RelayToProxy, &meta(), 4));
786
787        // Each key alone flips the AND to false.
788        assert!(!m.matches(ProxySide::ClientToProxy, &meta(), 4));
789        assert!(!m.matches(ProxySide::RelayToProxy, &ObjectMeta { group_id: 4, ..meta() }, 4));
790        assert!(!m.matches(ProxySide::RelayToProxy, &ObjectMeta { object_id: 13, ..meta() }, 4));
791        let fetch = ObjectMeta { stream_kind: DataStreamType::Fetch, ..meta() };
792        assert!(!m.matches(ProxySide::RelayToProxy, &fetch, 4));
793        assert!(!m.matches(ProxySide::RelayToProxy, &meta(), 5));
794
795        // A datagram rule never claims a framed object, on either kind.
796        let dgram = Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() };
797        assert!(!dgram.matches(ProxySide::ClientToProxy, &meta(), 0));
798        assert!(!dgram.matches(ProxySide::ClientToProxy, &fetch, 0));
799
800        // `n == 0` names no units.
801        let never = Matcher { every_nth: Some((0, 0)), ..Matcher::default() };
802        assert!(!never.matches(ProxySide::ClientToProxy, &meta(), 0));
803    }
804
805    /// **A `Fetch`-aimed rule claims a fetch object.** The positive half of
806    /// `kind_matches`, which nothing exercised: `the_other_keys_and_ends_a_match`
807    /// only asserts that a *Subgroup* rule rejects a fetch meta, and that
808    /// stays green with the `Fetch` arm deleted.
809    ///
810    /// The three assertions are the three things one arm has to get right:
811    /// the kind it names matches, the *other* stream kind does not, and the
812    /// `Datagram` kind matches neither — so a `kind_matches` rewritten as
813    /// `want != Datagram` would still redden.
814    ///
815    /// *Ablation, recorded:* delete `| (MatchKind::Fetch,
816    /// DataStreamType::Fetch)` from `kind_matches`. Before this test the
817    /// whole crate stayed green at 392 passed / 0 failed / 0 filtered out —
818    /// a matcher arm with no gate at all. With it:
819    ///
820    /// ```text
821    /// thread '...the_fetch_arm_claims_a_fetch_object' panicked at
822    /// crates\moqtap-proxy\src\shape\matcher.rs:
823    /// a Fetch-aimed class must claim a fetch object: the arm is live on all
824    /// drafts, every one of which has a fetch object codec
825    /// ```
826    #[test]
827    fn the_fetch_arm_claims_a_fetch_object() {
828        let fetch = ObjectMeta {
829            draft: DraftVersion::Draft14,
830            stream_kind: DataStreamType::Fetch,
831            track_alias: None,
832            ..meta()
833        };
834        let fetch_rule = Matcher { stream_kind: Some(MatchKind::Fetch), ..Matcher::default() };
835
836        assert!(
837            fetch_rule.matches(ProxySide::ClientToProxy, &fetch, 0),
838            "a Fetch-aimed class must claim a fetch object: the arm is live on all \
839             drafts, every one of which has a fetch object codec"
840        );
841        assert!(
842            !fetch_rule.matches(ProxySide::ClientToProxy, &meta(), 0),
843            "and must not claim a subgroup object, or `stream_kind` is not a key"
844        );
845        let dgram_rule = Matcher { stream_kind: Some(MatchKind::Datagram), ..Matcher::default() };
846        assert!(!dgram_rule.matches(ProxySide::ClientToProxy, &fetch, 0));
847    }
848
849    /// **Naming a stream kind never makes a rule unmatchable**, on any draft
850    /// and from either carrier.
851    ///
852    /// A *kind* is never dead on a *draft*: what can go missing is one fetch
853    /// stream at a time. A stream whose Group Order the session cannot
854    /// resolve is bypassed at its header, so no `ObjectMeta` is ever built
855    /// for a rule to see, and it reports itself as
856    /// `Impairment { FramerBypass { FetchGroupOrderUnknown } }`.
857    ///
858    /// The contrast is what keeps this from being a test that nothing can
859    /// fail: a key a fetch unit really cannot carry is still reported, on the
860    /// same drafts, through the same call. Every fetch header carries a
861    /// Request ID where a subgroup header carries a Track Alias, so a rule
862    /// keyed on the alias can never claim a fetch object on any draft — and
863    /// that is a fact about the *key* rather than about one header, which is
864    /// what makes it hold wherever the rule is measured.
865    ///
866    /// *Ablation:* drop the `TrackAlias` row from
867    /// [`Matcher::unmatchable_fields`]:
868    ///
869    /// ```text
870    /// assertion `left == right` failed: a fetch header carries a request id
871    /// where a subgroup header carries an alias, on every draft
872    ///   left: None
873    ///  right: Some(TrackAlias)
874    /// ```
875    #[test]
876    fn naming_a_stream_kind_never_makes_a_rule_unmatchable() {
877        let fetch_unit = |draft| ObjectMeta {
878            draft,
879            stream_kind: DataStreamType::Fetch,
880            track_alias: None,
881            ..meta()
882        };
883        let field = |m: &Matcher, unit: &ObjectMeta| {
884            m.unmatchable_fields(unit).into_iter().flatten().last()
885        };
886
887        for kind in [MatchKind::Subgroup, MatchKind::Fetch, MatchKind::Datagram] {
888            let rule = Matcher { stream_kind: Some(kind), ..Matcher::default() };
889            for draft in DRAFTS {
890                assert_eq!(
891                    field(&rule, &ObjectMeta { draft, ..meta() }),
892                    None,
893                    "{draft:?}: a rule aimed at {kind:?} failed to match a subgroup unit, \
894                     which is a rule working rather than a rule that cannot work"
895                );
896                assert_eq!(
897                    field(&rule, &fetch_unit(draft)),
898                    None,
899                    "{draft:?}: nor against a fetch unit"
900                );
901            }
902        }
903
904        // The contrast, on every draft: a key the carrier withholds.
905        let by_alias = Matcher { track_alias: Some(RangeSet::single(7)), ..Matcher::default() };
906        for draft in DRAFTS {
907            assert_eq!(
908                field(&by_alias, &fetch_unit(draft)),
909                Some(MatcherField::TrackAlias),
910                "a fetch header carries a request id where a subgroup header carries an \
911                 alias, on every draft"
912            );
913        }
914
915        // And from the datagram carrier, where the kind row also went: a
916        // datagram states its own alias and priority, so a rule aimed at any
917        // kind reports nothing about one.
918        let dgram = AnyDatagramMeta {
919            track_alias: 7,
920            group_id: 3,
921            object_id: 11,
922            publisher_priority: Some(128),
923            status: None,
924        };
925        for kind in [MatchKind::Subgroup, MatchKind::Fetch, MatchKind::Datagram] {
926            let rule = Matcher { stream_kind: Some(kind), ..Matcher::default() };
927            for draft in DRAFTS {
928                assert_eq!(
929                    rule.unmatchable_fields_datagram(draft, &dgram).into_iter().flatten().last(),
930                    None,
931                    "{draft:?}: the answer must not depend on which carrier asked"
932                );
933            }
934        }
935    }
936
937    /// **Every key that can name an empty set of values is detected**, one
938    /// row per key, so `ShapeProfile::try_new` can reject the class rather
939    /// than ship one that looks applied and claims nothing.
940    ///
941    /// The positive control on each row is the same key holding a
942    /// *non-empty* value: without it the table would pass against an
943    /// `inert_key` that answered `Some` for any key that was set at all,
944    /// which would reject every working profile.
945    ///
946    /// *Ablation, recorded:* drop the `every_nth` arm — the `n == 0` row
947    /// reddens with `left: None / right: Some("every_nth")`.
948    #[test]
949    fn an_empty_value_set_is_an_inert_key() {
950        // A literal `9..=1` is a clippy error at the call site; a runtime
951        // configuration can still produce one, which is the whole case.
952        let (lo, hi) = (9u64, 1u64);
953        let inverted = || RangeSet::new([lo..=hi]);
954        let ok = || RangeSet::single(1);
955
956        let (top, bottom) = (200u8, 100u8);
957        // `&dyn Fn` and not a `fn` pointer: the rows close over the locals
958        // above, which is what keeps a reversed literal out of the source.
959        type Edit<'a> = &'a dyn Fn(&mut Matcher, bool);
960        let rows: [(&str, Edit); 6] = [
961            ("track_alias", &|m, bad| m.track_alias = Some(if bad { inverted() } else { ok() })),
962            ("group_id", &|m, bad| m.group_id = Some(if bad { inverted() } else { ok() })),
963            ("subgroup_id", &|m, bad| m.subgroup_id = Some(if bad { inverted() } else { ok() })),
964            ("object_id", &|m, bad| m.object_id = Some(if bad { inverted() } else { ok() })),
965            ("priority", &|m, bad| {
966                m.priority = Some(if bad { top..=bottom } else { bottom..=top });
967            }),
968            ("every_nth", &|m, bad| m.every_nth = Some((if bad { 0 } else { 2 }, 0))),
969        ];
970
971        for (key, edit) in rows {
972            let mut inert = Matcher::default();
973            edit(&mut inert, true);
974            assert_eq!(inert.inert_key(), Some(key), "{key} names no value at all");
975
976            let mut live = Matcher::default();
977            edit(&mut live, false);
978            assert_eq!(live.inert_key(), None, "{key} holding a real value is a working rule");
979        }
980
981        // A matcher that keys on nothing claims everything, which is not
982        // inert — it is the default.
983        assert_eq!(Matcher::default().inert_key(), None);
984    }
985
986    /// Every side has a written form, and an unknown one is refused with the
987    /// four names listed.
988    #[cfg(feature = "serde")]
989    #[test]
990    fn sides_round_trip_by_name() {
991        #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
992        struct Holder {
993            #[serde(with = "super::side_serde")]
994            side: Option<crate::types::ProxySide>,
995        }
996
997        let holder = Holder { side: Some(crate::types::ProxySide::RelayToProxy) };
998        let json = serde_json::to_string(&holder).expect("serializes");
999        assert_eq!(json, r#"{"side":"relay-to-proxy"}"#);
1000        assert_eq!(serde_json::from_str::<Holder>(&json).expect("reads back"), holder);
1001
1002        let refusal = serde_json::from_str::<Holder>(r#"{"side":"relay->proxy"}"#)
1003            .expect_err("an unknown side is refused");
1004        assert!(
1005            refusal.to_string().contains("relay-to-proxy"),
1006            "the refusal lists the accepted names: {refusal}"
1007        );
1008    }
1009}