blazingly-aasa 0.1.5

Apple Associated Domains (apple-app-site-association) semantics: parse, validate, match, explain, and diff
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
//! Turning bytes into the wire model.
//!
//! Parsing is lenient by design: only invalid JSON, a non-object root, or an oversized payload
//! fail outright. Everything else — a `details` array holding a number, a `?` predicate that is a
//! boolean, an unrecognized top-level key — is recorded as a structural diagnostic and skipped, so
//! one bad entry never hides the rest of the file. Apple adds keys over time; rejecting a document
//! because of an unfamiliar one would be worse than ignoring it.

use crate::diagnostics::{Diagnostic, DiagnosticCode};
use crate::error::{ParseError, ParseErrorKind};
use crate::model::{
    AasaDocument, AppLinkDetail, AppLinks, AppService, ComponentRule, MatchDefaults,
    QueryPredicate, QueryRule,
};
use blazingly_json::{Map, Value};
use std::collections::BTreeMap;

/// Limits applied while parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseOptions {
    max_bytes: usize,
}

impl ParseOptions {
    /// The default payload ceiling, in bytes.
    ///
    /// This is a defensive policy chosen by this crate for handling remote, attacker-controlled
    /// input — not a limit Apple states in the reference pages this crate cites. Raise or lower it
    /// freely with [`ParseOptions::max_bytes`].
    pub const DEFAULT_MAX_BYTES: usize = 128 * 1024;

    /// Options with the default limits.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the maximum accepted payload size, in bytes.
    #[must_use]
    pub fn max_bytes(mut self, bytes: usize) -> Self {
        self.max_bytes = bytes;
        self
    }

    /// The configured payload ceiling.
    #[must_use]
    pub fn max_bytes_value(&self) -> usize {
        self.max_bytes
    }
}

impl Default for ParseOptions {
    fn default() -> Self {
        Self {
            max_bytes: Self::DEFAULT_MAX_BYTES,
        }
    }
}

pub(crate) fn parse(bytes: &[u8], options: &ParseOptions) -> Result<AasaDocument, ParseError> {
    if bytes.len() > options.max_bytes {
        return Err(ParseError::new(
            ParseErrorKind::TooLarge {
                limit: options.max_bytes,
                actual: bytes.len(),
            },
            format!(
                "payload is {} bytes, above the configured {} byte limit",
                bytes.len(),
                options.max_bytes
            ),
        ));
    }

    // Association files served for iOS 9 are CMS-signed DER rather than JSON. JSON is tried
    // first, because the DER SEQUENCE tag 0x30 is also the ASCII digit `0` -- sniffing on the
    // leading byte alone would report a document of `0` as a signing problem. A real DER blob
    // fails JSON parsing immediately, so the fallback costs nothing.
    let mut signed = None;
    let value: Value = match blazingly_json::from_slice(bytes) {
        Ok(value) => value,
        Err(json_error) => {
            if !crate::signed::is_signed_data(bytes) {
                return Err(ParseError::new(
                    ParseErrorKind::Json,
                    json_error.to_string(),
                ));
            }
            let Some(payload) = crate::signed::extract_payload(bytes) else {
                return Err(ParseError::new(
                    ParseErrorKind::Json,
                    "this is a CMS signedData structure, as iOS 9 association files were, but no \
                     encapsulated JSON content could be read from it"
                        .to_owned(),
                ));
            };
            let value = blazingly_json::from_slice(&payload).map_err(|error| {
                ParseError::new(
                    ParseErrorKind::Json,
                    format!("the CMS-signed payload is not valid JSON: {error}"),
                )
            })?;
            signed = Some(payload);
            value
        }
    };

    let Value::Object(root) = value else {
        return Err(ParseError::new(
            ParseErrorKind::RootNotObject,
            format!(
                "root value is {}, but apple-app-site-association must be a JSON object",
                type_name(&value)
            ),
        ));
    };

    let mut walker = Walker::default();
    if signed.is_some() {
        walker.push(
            Diagnostic::new(
                DiagnosticCode::SignedPayload,
                "",
                "this is a CMS-signed association file; its JSON payload was extracted but the \
                 signature was NOT verified",
            )
            .with_help(
                "signing was only required for iOS 9; iOS 10 and later expect unsigned JSON served \
                 over https",
            ),
        );
    }
    let mut document = AasaDocument {
        applinks: None,
        webcredentials: None,
        appclips: None,
        activitycontinuation: None,
        unknown_keys: Vec::new(),
        structural: Vec::new(),
        byte_len: bytes.len(),
    };

    for (key, value) in &root {
        match key.as_str() {
            "applinks" => document.applinks = walker.applinks(value, "applinks"),
            "webcredentials" => document.webcredentials = walker.service(value, "webcredentials"),
            "appclips" => document.appclips = walker.service(value, "appclips"),
            "activitycontinuation" => {
                document.activitycontinuation = walker.service(value, "activitycontinuation");
            }
            other => {
                document.unknown_keys.push(other.to_owned());
                walker.push(
                    Diagnostic::new(
                        DiagnosticCode::UnknownTopLevelKey,
                        other,
                        format!("`{other}` is not an Associated Domains service this crate knows"),
                    )
                    .with_help(
                        "it is ignored; Apple ignores unknown keys too, so this is only a heads-up",
                    ),
                );
            }
        }
    }

    document.structural = walker.diagnostics;
    Ok(document)
}

fn type_name(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "a boolean",
        Value::Number(_) => "a number",
        Value::String(_) => "a string",
        Value::Array(_) => "an array",
        Value::Object(_) => "an object",
    }
}

#[derive(Default)]
struct Walker {
    diagnostics: Vec<Diagnostic>,
}

impl Walker {
    fn push(&mut self, diagnostic: Diagnostic) {
        self.diagnostics.push(diagnostic);
    }

    /// Diagnostic paths are built by a closure so the happy path never formats a string. Parsing
    /// a healthy document is the overwhelmingly common case, and it used to spend more time
    /// building paths for diagnostics it never emitted than it did parsing JSON.
    fn mismatch(&mut self, path: impl FnOnce() -> String, expected: &str, value: &Value) {
        self.push(Diagnostic::new(
            DiagnosticCode::FieldTypeMismatch,
            path(),
            format!("expected {expected} but found {}", type_name(value)),
        ));
    }

    fn object<'a>(
        &mut self,
        value: &'a Value,
        path: impl FnOnce() -> String,
    ) -> Option<&'a Map<String, Value>> {
        match value {
            Value::Object(object) => Some(object),
            other => {
                self.mismatch(path, "an object", other);
                None
            }
        }
    }

    fn string(&mut self, value: &Value, path: impl FnOnce() -> String) -> Option<String> {
        match value {
            Value::String(text) => Some(text.clone()),
            other => {
                self.mismatch(path, "a string", other);
                None
            }
        }
    }

    fn bool(&mut self, value: &Value, path: impl FnOnce() -> String) -> Option<bool> {
        match value {
            Value::Bool(flag) => Some(*flag),
            other => {
                self.mismatch(path, "a boolean", other);
                None
            }
        }
    }

    fn string_array(&mut self, value: &Value, path: &str) -> Option<Vec<String>> {
        let Value::Array(items) = value else {
            self.mismatch(|| path.to_owned(), "an array of strings", value);
            return None;
        };
        let mut out = Vec::with_capacity(items.len());
        for (index, item) in items.iter().enumerate() {
            if let Some(text) = self.string(item, || format!("{path}[{index}]")) {
                out.push(text);
            }
        }
        Some(out)
    }

    fn service(&mut self, value: &Value, path: &str) -> Option<AppService> {
        if value.is_null() {
            return None;
        }
        let object = self.object(value, || path.to_owned())?;
        let apps = match object.get("apps") {
            Some(apps) => self
                .string_array(apps, &format!("{path}.apps"))
                .unwrap_or_default(),
            None => Vec::new(),
        };
        Some(AppService { apps })
    }

    fn applinks(&mut self, value: &Value, path: &str) -> Option<AppLinks> {
        if value.is_null() {
            return None;
        }
        let object = self.object(value, || path.to_owned())?;
        let mut applinks = AppLinks::default();

        if let Some(apps) = object.get("apps") {
            applinks.apps = self.string_array(apps, &format!("{path}.apps"));
        }
        if let Some(defaults) = object.get("defaults") {
            applinks.defaults = self.defaults(defaults, &format!("{path}.defaults"));
        }
        if let Some(variables) = object.get("substitutionVariables") {
            applinks.substitution_variables =
                self.substitutions(variables, &format!("{path}.substitutionVariables"));
        }
        if let Some(details) = object.get("details") {
            let details_path = format!("{path}.details");
            match details {
                Value::Array(items) => {
                    applinks.details = items
                        .iter()
                        .enumerate()
                        .filter_map(|(index, item)| {
                            self.detail(item, &format!("{details_path}[{index}]"), None)
                        })
                        .collect();
                }
                Value::Object(entries) => {
                    applinks.details_were_dictionary = true;
                    self.push(
                        Diagnostic::new(
                            DiagnosticCode::LegacyDetailsDictionary,
                            &details_path,
                            "`details` is a dictionary keyed by application identifier, the oldest \
                             association-file form",
                        )
                        .with_help(
                            "migrate to an array of detail objects; a dictionary has no defined \
                             rule order, so this crate evaluates the keys in sorted order",
                        ),
                    );
                    applinks.details = entries
                        .iter()
                        .filter_map(|(app_id, item)| {
                            self.detail(
                                item,
                                &format!("{details_path}.{app_id}"),
                                Some(app_id.clone()),
                            )
                        })
                        .collect();
                }
                Value::Null => {}
                other => {
                    self.mismatch(|| details_path.clone(), "an array of detail objects", other);
                }
            }
        }
        Some(applinks)
    }

    fn detail(
        &mut self,
        value: &Value,
        path: &str,
        implied_app_id: Option<String>,
    ) -> Option<AppLinkDetail> {
        let object = self.object(value, || path.to_owned())?;
        let mut detail = AppLinkDetail {
            app_id: implied_app_id,
            ..AppLinkDetail::default()
        };

        if let Some(app_id) = object.get("appID") {
            detail.app_id = self.string(app_id, || format!("{path}.appID"));
        }
        if let Some(app_ids) = object.get("appIDs") {
            detail.app_ids = self.string_array(app_ids, &format!("{path}.appIDs"));
        }
        if let Some(defaults) = object.get("defaults") {
            detail.defaults = self.defaults(defaults, &format!("{path}.defaults"));
        }
        if let Some(paths) = object.get("paths") {
            detail.paths = self.string_array(paths, &format!("{path}.paths"));
        }
        if let Some(components) = object.get("components") {
            let components_path = format!("{path}.components");
            match components {
                Value::Array(items) => {
                    detail.components = Some(
                        items
                            .iter()
                            .enumerate()
                            .filter_map(|(index, item)| {
                                self.component(item, &format!("{components_path}[{index}]"))
                            })
                            .collect(),
                    );
                }
                other => self.mismatch(
                    || components_path.clone(),
                    "an array of component objects",
                    other,
                ),
            }
        }
        Some(detail)
    }

    fn component(&mut self, value: &Value, path: &str) -> Option<ComponentRule> {
        let object = self.object(value, || path.to_owned())?;
        let mut rule = ComponentRule::default();
        for (key, value) in object {
            let field = || format!("{path}.{key}");
            match key.as_str() {
                "/" => rule.path = self.string(value, field),
                "#" => rule.fragment = self.string(value, field),
                // `query` needs the path for its own nested diagnostics, so it pays the format
                // only when the component actually carries a `?`.
                "?" => rule.query = self.query(value, &field()),
                "exclude" => rule.exclude = self.bool(value, field),
                "caseSensitive" => rule.case_sensitive = self.bool(value, field),
                "percentEncoded" => rule.percent_encoded = self.bool(value, field),
                "comment" => rule.comment = self.string(value, field),
                _ => {}
            }
        }
        Some(rule)
    }

    fn query(&mut self, value: &Value, path: &str) -> Option<QueryRule> {
        match value {
            Value::String(pattern) => Some(QueryRule::Whole(pattern.clone())),
            Value::Object(items) => {
                let mut predicates = BTreeMap::new();
                for (key, value) in items {
                    let predicate = match value {
                        Value::String(pattern) => QueryPredicate::Pattern(pattern.clone()),
                        other => {
                            self.push(
                                Diagnostic::new(
                                    DiagnosticCode::UnsupportedQueryPredicate,
                                    format!("{path}.{key}"),
                                    format!(
                                        "query predicate is {}, but Apple documents only string \
                                         patterns here",
                                        type_name(other)
                                    ),
                                )
                                .with_help(
                                    "Apple ignores the entire query dictionary when any predicate \
                                     is not a string, so every query constraint in this rule stops \
                                     applying and the rule matches more URLs, not fewer. Replace \
                                     every predicate with a string pattern.",
                                ),
                            );
                            QueryPredicate::Unsupported {
                                json_type: match other {
                                    Value::Null => "null",
                                    Value::Bool(_) => "boolean",
                                    Value::Number(_) => "number",
                                    Value::Array(_) => "array",
                                    Value::Object(_) => "object",
                                    Value::String(_) => "string",
                                },
                            }
                        }
                    };
                    predicates.insert(key.clone(), predicate);
                }
                Some(QueryRule::Items(predicates))
            }
            other => {
                self.mismatch(
                    || path.to_owned(),
                    "a string pattern or an object of predicates",
                    other,
                );
                None
            }
        }
    }

    fn defaults(&mut self, value: &Value, path: &str) -> Option<MatchDefaults> {
        let object = self.object(value, || path.to_owned())?;
        let mut defaults = MatchDefaults::default();
        for (key, value) in object {
            let field = || format!("{path}.{key}");
            match key.as_str() {
                "caseSensitive" => defaults.case_sensitive = self.bool(value, field),
                "percentEncoded" => defaults.percent_encoded = self.bool(value, field),
                other => defaults.other_keys.push(other.to_owned()),
            }
        }
        if !defaults.other_keys.is_empty() {
            let keys = defaults.other_keys.join(", ");
            self.push(
                Diagnostic::new(
                    DiagnosticCode::DefaultsContainsPatternKeys,
                    path,
                    format!("`defaults` also carries {keys}"),
                )
                .with_help(
                    "Apple documents `defaults` as a subclass of `components`, but does not \
                     specify what a pattern key means there; this crate applies only \
                     caseSensitive and percentEncoded",
                ),
            );
        }
        Some(defaults)
    }

    fn substitutions(&mut self, value: &Value, path: &str) -> BTreeMap<String, Vec<String>> {
        let Some(object) = self.object(value, || path.to_owned()) else {
            return BTreeMap::new();
        };
        let mut out = BTreeMap::new();
        for (name, value) in object {
            if let Some(values) = self.string_array(value, &format!("{path}.{name}")) {
                out.insert(name.clone(), values);
            }
        }
        out
    }
}