Skip to main content

blazingly_aasa/
lib.rs

1//! Apple Associated Domains semantics for Rust and WebAssembly.
2//!
3//! `blazingly-aasa` parses, validates, matches, explains, and compares `apple-app-site-association`
4//! files. It is a semantic engine, not a fetcher: it never touches the network, never opens an
5//! `.ipa`, and never claims to know what a device will do. Give it bytes and explicit context, and
6//! it tells you exactly what the document says.
7//!
8//! # Three separate questions
9//!
10//! 1. **Is this parseable?** [`AasaDocument::parse`] fails only on invalid JSON, a non-object root,
11//!    or an oversized payload.
12//! 2. **Is this sane?** [`CompiledAasa::validate`] returns a [`ValidationReport`] of stable,
13//!    machine-readable [`DiagnosticCode`]s rather than a single yes/no.
14//! 3. **Does this URL match?** [`CompiledAasa::match_url`] returns [`MatchDecision::Match`],
15//!    [`MatchDecision::Exclude`], or [`MatchDecision::NoMatch`] — with a trace explaining why.
16//!
17//! A URL that does not match is not an error, and neither is one that is excluded. Both are
18//! answers.
19//!
20//! # Matching a URL
21//!
22//! ```
23//! use blazingly_aasa::{CompiledAasa, MatchDecision};
24//!
25//! let bytes = br#"{
26//!   "applinks": {
27//!     "details": [{
28//!       "appIDs": ["ABCDE12345.com.example.app"],
29//!       "components": [
30//!         { "/": "/help/website/*", "exclude": true },
31//!         { "/": "/help/*", "?": { "articleNumber": "????" } }
32//!       ]
33//!     }]
34//!   }
35//! }"#;
36//!
37//! let aasa = CompiledAasa::parse(bytes)?;
38//! let app = "ABCDE12345.com.example.app";
39//!
40//! let hit = aasa.match_url("example.com", app, "https://example.com/help/1?articleNumber=4815")?;
41//! assert_eq!(hit.decision, MatchDecision::Match);
42//!
43//! let blocked = aasa.match_url("example.com", app, "https://example.com/help/website/faq")?;
44//! assert_eq!(blocked.decision, MatchDecision::Exclude);
45//!
46//! // Three characters, not four: the query predicate rejects it.
47//! let miss = aasa.match_url("example.com", app, "https://example.com/help/1?articleNumber=481")?;
48//! assert_eq!(miss.decision, MatchDecision::NoMatch);
49//! # Ok::<(), blazingly_aasa::Error>(())
50//! ```
51//!
52//! # Explaining a decision
53//!
54//! Every result formats itself into something you can paste into a bug report:
55//!
56//! ```
57//! # use blazingly_aasa::CompiledAasa;
58//! # let bytes = br#"{"applinks":{"details":[{"appIDs":["A.b"],"components":[{"/":"/buy/*"}]}]}}"#;
59//! # let aasa = CompiledAasa::parse(bytes)?;
60//! let result = aasa.match_url("example.com", "A.b", "https://example.com/sell/42")?;
61//! println!("{result}");
62//! # Ok::<(), blazingly_aasa::Error>(())
63//! ```
64//!
65//! # Comparing two files
66//!
67//! [`CompiledAasa::semantic_diff`] compares behaviour rather than text, so moving
68//! `caseSensitive` from every component up into `defaults` reports no change, while reordering two
69//! rules does:
70//!
71//! ```
72//! use blazingly_aasa::CompiledAasa;
73//!
74//! let spelled_out = CompiledAasa::parse(br#"{"applinks":{"details":[{
75//!     "appIDs": ["A.b"],
76//!     "components": [{ "/": "/buy/*", "caseSensitive": false }]
77//! }]}}"#)?;
78//!
79//! let refactored = CompiledAasa::parse(br#"{"applinks":{"details":[{
80//!     "appIDs": ["A.b"],
81//!     "defaults": { "caseSensitive": false },
82//!     "components": [{ "/": "/buy/*" }]
83//! }]}}"#)?;
84//!
85//! assert!(spelled_out.semantic_diff(&refactored).is_equivalent());
86//! assert!(!spelled_out.structural_equal(&refactored));
87//! # Ok::<(), blazingly_aasa::Error>(())
88//! ```
89//!
90//! # What this crate will not do
91//!
92//! It does not fetch `.well-known/apple-app-site-association`, talk to Apple's CDN, read
93//! entitlements out of a signed binary, or model device state. Those belong in the tools that use
94//! this crate. See `docs/parity.md` for the behaviours that are verified against Apple's
95//! documentation and the ones that are still open questions.
96
97#![forbid(unsafe_code)]
98
99mod compile;
100mod diagnostics;
101mod diff;
102mod error;
103mod explain;
104mod iso_tables;
105mod matcher;
106mod model;
107mod normalize;
108mod parse;
109mod pattern;
110mod signed;
111mod substitution;
112mod url;
113mod validate;
114mod wildcard;
115
116pub use compile::{CompiledAasa, EffectiveQuery, EffectiveRule, Service};
117pub use diagnostics::{Diagnostic, DiagnosticCode, Severity, ValidationReport};
118pub use diff::{AasaDiff, SemanticChange};
119pub use error::{Error, ParseError, ParseErrorKind, Result, UrlError};
120pub use explain::{
121    ComponentReason, ComponentTrace, DetailTrace, MatchDecision, MatchResult, MatchTrace,
122    RuleTrace, StopReason, UrlComponent,
123};
124pub use model::{
125    AasaDocument, AppLinkDetail, AppLinks, AppService, ComponentRule, EffectiveDefaults,
126    MatchDefaults, QueryPredicate, QueryRule, DEFAULT_CASE_SENSITIVE, DEFAULT_PERCENT_ENCODED,
127};
128pub use parse::ParseOptions;
129pub use url::{percent_decode, strip_leading_slash, trim_path, UrlParts};
130pub use wildcard::{PatternSyntaxError, WildcardPattern};
131
132/// The Foundation release the `$(region)` and `$(lang)` tables were generated from.
133///
134/// Apple defines those variables as `Locale.isoRegionCodes` and `Locale.isoLanguageCodes`, which
135/// change between OS releases. Knowing which snapshot you are matching against matters.
136pub const ISO_TABLE_SOURCE: &str = substitution::ISO_TABLE_SOURCE;
137
138impl AasaDocument {
139    /// Parses `apple-app-site-association` bytes with the default limits.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
144    /// Structural problems inside the document are reported by [`CompiledAasa::validate`] instead.
145    pub fn parse(bytes: &[u8]) -> std::result::Result<Self, ParseError> {
146        parse::parse(bytes, &ParseOptions::default())
147    }
148
149    /// Parses with explicit limits.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
154    pub fn parse_with(
155        bytes: &[u8],
156        options: &ParseOptions,
157    ) -> std::result::Result<Self, ParseError> {
158        parse::parse(bytes, options)
159    }
160
161    /// Parses from a string.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
166    pub fn parse_str(input: &str) -> std::result::Result<Self, ParseError> {
167        Self::parse(input.as_bytes())
168    }
169
170    /// Normalizes the document for matching, explaining, and comparing.
171    #[must_use]
172    pub fn compile(&self) -> CompiledAasa {
173        compile::compile(self)
174    }
175
176    /// Validates the document. Equivalent to `self.compile().validate()`.
177    #[must_use]
178    pub fn validate(&self) -> ValidationReport {
179        self.compile().validate()
180    }
181
182    /// The size of the payload this document was parsed from, in bytes.
183    #[must_use]
184    pub fn byte_len(&self) -> usize {
185        self.byte_len
186    }
187}
188
189/// Splits `ABCDE12345.com.example.app` into its application identifier prefix and bundle
190/// identifier.
191///
192/// Apple documents the form as `<Application Identifier Prefix>.<Bundle Identifier>`, and the
193/// prefix never contains a dot, so the split is at the first one. Returns `None` when there is no
194/// dot, or when either half would be empty.
195///
196/// ```
197/// assert_eq!(
198///     blazingly_aasa::split_app_id("ABCDE12345.com.example.app"),
199///     Some(("ABCDE12345", "com.example.app")),
200/// );
201/// assert_eq!(blazingly_aasa::split_app_id("nodots"), None);
202/// ```
203#[must_use]
204pub fn split_app_id(app_id: &str) -> Option<(&str, &str)> {
205    let (prefix, bundle) = app_id.split_once('.')?;
206    (!prefix.is_empty() && !bundle.is_empty()).then_some((prefix, bundle))
207}
208
209/// Parses and validates in one call.
210///
211/// # Errors
212///
213/// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
214pub fn validate(bytes: &[u8]) -> std::result::Result<ValidationReport, ParseError> {
215    Ok(CompiledAasa::parse(bytes)?.validate())
216}
217
218/// Parses and matches in one call.
219///
220/// Prefer [`CompiledAasa::match_url`] when testing more than one URL against the same document:
221/// this helper reparses and recompiles every time.
222///
223/// # Errors
224///
225/// Returns [`Error::Parse`] for an unusable document and [`Error::Url`] for an unusable URL.
226pub fn match_url(bytes: &[u8], domain: &str, app_id: &str, url: &str) -> Result<MatchResult> {
227    let compiled = CompiledAasa::parse(bytes)?;
228    Ok(compiled.match_url(domain, app_id, url)?)
229}
230
231/// Parses both documents and compares them semantically.
232///
233/// # Errors
234///
235/// Returns [`ParseError`] when either payload is unusable.
236pub fn diff(left: &[u8], right: &[u8]) -> std::result::Result<AasaDiff, ParseError> {
237    let left = CompiledAasa::parse(left)?;
238    let right = CompiledAasa::parse(right)?;
239    Ok(left.semantic_diff(&right))
240}