Skip to main content

blazingly_aasa/
explain.rs

1//! Match traces: why a URL matched, was excluded, or was ignored.
2//!
3//! Every [`MatchResult`](crate::MatchResult) carries a trace. A boolean tells you nothing when a
4//! universal link mysteriously stops working; the trace names the detail entry, the rule index,
5//! the effective settings that rule ran under, and the exact component that failed.
6
7use crate::model::EffectiveDefaults;
8use serde::Serialize;
9use std::fmt;
10
11/// The outcome of matching a URL against a document.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum MatchDecision {
15    /// A rule matched and did not exclude the URL.
16    Match,
17    /// The first matching rule set `exclude: true`.
18    Exclude,
19    /// No rule matched.
20    NoMatch,
21}
22
23impl fmt::Display for MatchDecision {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        f.write_str(match self {
26            Self::Match => "MATCH",
27            Self::Exclude => "BLOCK",
28            Self::NoMatch => "NO_MATCH",
29        })
30    }
31}
32
33/// Which part of the URL a component trace refers to.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case", tag = "component", content = "name")]
36pub enum UrlComponent {
37    /// The `/` key.
38    Path,
39    /// The `?` key, matched as one string.
40    Query,
41    /// One named predicate inside a `?` dictionary.
42    QueryItem(String),
43    /// The `#` key.
44    Fragment,
45}
46
47impl fmt::Display for UrlComponent {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::Path => f.write_str("path"),
51            Self::Query => f.write_str("query"),
52            Self::QueryItem(name) => write!(f, "query[{name}]"),
53            Self::Fragment => f.write_str("fragment"),
54        }
55    }
56}
57
58/// Why one component matched or failed.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "snake_case")]
61#[non_exhaustive]
62pub enum ComponentReason {
63    /// The rule does not constrain this component, so it accepts anything.
64    Unconstrained,
65    /// A literal pattern compared equal.
66    Exact,
67    /// A wildcard pattern matched.
68    Wildcard,
69    /// A pattern containing `$(...)` matched.
70    Substitution,
71    /// The pattern did not match.
72    PatternMismatch,
73    /// The pattern would have matched if `caseSensitive` were `false`.
74    CaseMismatch,
75    /// A `?` dictionary named a query item the URL does not carry.
76    MissingQueryItem,
77    /// A `?` predicate was not a string, so Apple ignored the whole `?` dictionary and the
78    /// component constrained nothing.
79    UnsupportedPredicate,
80}
81
82impl ComponentReason {
83    /// Whether this reason represents a successful comparison.
84    #[must_use]
85    pub fn is_match(self) -> bool {
86        matches!(
87            self,
88            Self::Unconstrained
89                | Self::Exact
90                | Self::Wildcard
91                | Self::Substitution
92                | Self::UnsupportedPredicate
93        )
94    }
95}
96
97impl fmt::Display for ComponentReason {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(match self {
100            Self::Unconstrained => "not constrained by this rule",
101            Self::Exact => "literal match",
102            Self::Wildcard => "wildcard match",
103            Self::Substitution => "substitution match",
104            Self::PatternMismatch => "pattern did not match",
105            Self::CaseMismatch => "differs only by letter case",
106            Self::MissingQueryItem => "query item is missing",
107            Self::UnsupportedPredicate => {
108                "predicate is not a string, so the whole query dictionary is ignored"
109            }
110        })
111    }
112}
113
114/// One component comparison inside a rule.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct ComponentTrace {
117    /// Which URL component was compared.
118    #[serde(flatten)]
119    pub component: UrlComponent,
120    /// The pattern as written in the document, when the rule specified one.
121    pub pattern: Option<String>,
122    /// The value taken from the URL, after any percent-decoding the rule asked for.
123    pub input: String,
124    /// Whether this component accepted the URL.
125    pub matched: bool,
126    /// Why.
127    pub reason: ComponentReason,
128}
129
130/// One rule evaluation.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
132pub struct RuleTrace {
133    /// Index of the owning entry in `applinks.details`.
134    pub detail_index: usize,
135    /// Index of this rule inside the entry, counting `components` or legacy `paths`.
136    pub rule_index: usize,
137    /// Whether this rule came from legacy `paths` rather than `components`.
138    pub legacy: bool,
139    /// Whether the rule carries `exclude: true`.
140    pub exclude: bool,
141    /// The rule's `comment`, when it has one.
142    pub comment: Option<String>,
143    /// The settings this rule ran under, after resolving the defaults hierarchy.
144    pub effective: EffectiveDefaults,
145    /// Every component this rule compared.
146    pub components: Vec<ComponentTrace>,
147    /// Whether every specified component matched.
148    pub matched: bool,
149}
150
151impl RuleTrace {
152    /// How many specified components matched, used to pick the closest near-miss.
153    #[must_use]
154    pub fn matched_component_count(&self) -> usize {
155        self.components
156            .iter()
157            .filter(|component| {
158                component.matched && component.reason != ComponentReason::Unconstrained
159            })
160            .count()
161    }
162}
163
164/// One `applinks.details` entry considered during matching.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
166pub struct DetailTrace {
167    /// Index in `applinks.details`.
168    pub index: usize,
169    /// The application identifiers this entry declares.
170    pub app_ids: Vec<String>,
171    /// Whether the entry lists the application identifier under test.
172    pub applies: bool,
173    /// Rules evaluated, empty when the entry does not apply.
174    pub rules: Vec<RuleTrace>,
175}
176
177/// Why matching stopped where it did.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179#[serde(rename_all = "snake_case", tag = "stop")]
180#[non_exhaustive]
181pub enum StopReason {
182    /// A rule matched.
183    Matched,
184    /// A rule matched and excluded the URL.
185    Excluded,
186    /// The document has no `applinks` section.
187    NoAppLinksSection,
188    /// No `applinks.details` entry lists this application identifier.
189    NoApplicableDetail,
190    /// Entries applied, but none of their rules matched.
191    NoRuleMatched,
192    /// The URL's host is not the domain this document was served for.
193    HostMismatch {
194        /// The domain the document was served for.
195        expected: String,
196        /// The host in the URL.
197        actual: String,
198    },
199}
200
201/// The full record of a match attempt.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
203pub struct MatchTrace {
204    /// Every `applinks.details` entry, in source order.
205    pub details: Vec<DetailTrace>,
206    /// The detail index of the deciding rule, when there was one.
207    pub selected_detail: Option<usize>,
208    /// The rule index of the deciding rule, when there was one.
209    pub selected_rule: Option<usize>,
210    /// Why matching stopped.
211    pub stop_reason: StopReason,
212    /// The rule that came closest, when nothing matched.
213    pub closest_failure: Option<RuleTrace>,
214}
215
216/// The result of matching one URL for one application identifier.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218pub struct MatchResult {
219    /// Match, exclude, or no match.
220    pub decision: MatchDecision,
221    /// The domain the document was served for.
222    pub domain: String,
223    /// The application identifier under test.
224    pub app_id: String,
225    /// The URL under test.
226    pub url: String,
227    /// Why.
228    pub trace: MatchTrace,
229    /// Context-level warnings that do not affect the decision, such as a non-HTTPS scheme.
230    pub notes: Vec<String>,
231}
232
233impl MatchResult {
234    /// Whether the document considers this URL openable by this app.
235    #[must_use]
236    pub fn is_match(&self) -> bool {
237        self.decision == MatchDecision::Match
238    }
239
240    /// The deciding rule, when the decision came from one.
241    #[must_use]
242    pub fn selected_rule(&self) -> Option<&RuleTrace> {
243        let detail = self.trace.selected_detail?;
244        let rule = self.trace.selected_rule?;
245        self.trace
246            .details
247            .iter()
248            .find(|entry| entry.index == detail)?
249            .rules
250            .iter()
251            .find(|candidate| candidate.rule_index == rule)
252    }
253}
254
255impl fmt::Display for MatchResult {
256    #[allow(clippy::too_many_lines)]
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        writeln!(f, "{}", self.decision)?;
259        writeln!(f)?;
260        writeln!(f, "application: {}", self.app_id)?;
261        writeln!(f, "domain:      {}", self.domain)?;
262        writeln!(f, "url:         {}", self.url)?;
263
264        if let Some(rule) = self.selected_rule() {
265            writeln!(f)?;
266            writeln!(f, "detail:      #{}", rule.detail_index)?;
267            writeln!(
268                f,
269                "rule:        #{}{}",
270                rule.rule_index,
271                if rule.legacy { " (legacy paths)" } else { "" }
272            )?;
273            if let Some(comment) = &rule.comment {
274                writeln!(f, "comment:     {comment}")?;
275            }
276            writeln!(f)?;
277            write_components(f, rule)?;
278            writeln!(f)?;
279            writeln!(
280                f,
281                "effective settings:\n  caseSensitive  = {}\n  percentEncoded = {}",
282                rule.effective.case_sensitive, rule.effective.percent_encoded
283            )?;
284        }
285
286        writeln!(f)?;
287        writeln!(f, "reason:")?;
288        match &self.trace.stop_reason {
289            StopReason::Matched => {
290                writeln!(f, "  every component this rule specifies matched")?;
291            }
292            StopReason::Excluded => {
293                writeln!(
294                    f,
295                    "  the first matching rule sets exclude: true, so matching stopped"
296                )?;
297            }
298            StopReason::NoAppLinksSection => {
299                writeln!(f, "  the document has no applinks section")?;
300            }
301            StopReason::NoApplicableDetail => {
302                writeln!(f, "  no applinks.details entry lists {}", self.app_id)?;
303            }
304            StopReason::NoRuleMatched => {
305                writeln!(
306                    f,
307                    "  the entries that apply to {} have no rule matching this URL",
308                    self.app_id
309                )?;
310            }
311            StopReason::HostMismatch { expected, actual } => {
312                writeln!(
313                    f,
314                    "  the URL host is {actual}, but this document was served for {expected}"
315                )?;
316            }
317        }
318
319        if let Some(closest) = &self.trace.closest_failure {
320            writeln!(f)?;
321            writeln!(
322                f,
323                "closest failure:\n  detail #{}, rule #{}",
324                closest.detail_index, closest.rule_index
325            )?;
326            write_components(f, closest)?;
327        }
328
329        for note in &self.notes {
330            writeln!(f, "\nnote: {note}")?;
331        }
332        Ok(())
333    }
334}
335
336fn write_components(f: &mut fmt::Formatter<'_>, rule: &RuleTrace) -> fmt::Result {
337    for component in &rule.components {
338        if component.reason == ComponentReason::Unconstrained {
339            continue;
340        }
341        let mark = if component.matched { "ok  " } else { "FAIL" };
342        writeln!(f, "  [{mark}] {}", component.component)?;
343        writeln!(f, "         url:     {}", component.input)?;
344        if let Some(pattern) = &component.pattern {
345            writeln!(f, "         pattern: {pattern}")?;
346        }
347        writeln!(f, "         {}", component.reason)?;
348    }
349    Ok(())
350}