1use crate::diagnostics::{Diagnostic, DiagnosticCode, ValidationReport};
8use crate::error::ParseError;
9use crate::model::{
10 AasaDocument, AppLinkDetail, AppLinks, ComponentRule, EffectiveDefaults, QueryPredicate,
11 QueryRule,
12};
13use crate::parse::ParseOptions;
14use crate::pattern::{Pattern, PatternError};
15use crate::substitution::SubstitutionTable;
16use serde::Serialize;
17use std::collections::{BTreeMap, BTreeSet};
18
19pub(crate) const SERVICES: [Service; 4] = [
20 Service::AppLinks,
21 Service::WebCredentials,
22 Service::AppClips,
23 Service::ActivityContinuation,
24];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Service {
30 AppLinks,
32 WebCredentials,
34 AppClips,
36 ActivityContinuation,
38}
39
40impl Service {
41 #[must_use]
43 pub const fn key(self) -> &'static str {
44 match self {
45 Self::AppLinks => "applinks",
46 Self::WebCredentials => "webcredentials",
47 Self::AppClips => "appclips",
48 Self::ActivityContinuation => "activitycontinuation",
49 }
50 }
51}
52
53impl std::fmt::Display for Service {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(self.key())
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EffectiveQuery {
63 Whole(String),
65 Items(BTreeMap<String, Option<String>>),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
76#[allow(clippy::struct_excessive_bools)]
79pub struct EffectiveRule {
80 pub exclude: bool,
82 pub legacy: bool,
84 pub path: Option<String>,
86 pub query: Option<EffectiveQuery>,
88 pub fragment: Option<String>,
90 pub case_sensitive: bool,
92 pub percent_encoded: bool,
94}
95
96impl std::fmt::Display for EffectiveRule {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 let mut parts: Vec<String> = Vec::new();
99 if let Some(path) = &self.path {
100 parts.push(format!("/ = {path}"));
101 }
102 match &self.query {
103 Some(EffectiveQuery::Whole(pattern)) => parts.push(format!("? = {pattern}")),
104 Some(EffectiveQuery::Items(items)) => {
105 let rendered = items
106 .iter()
107 .map(|(key, value)| match value {
108 Some(pattern) => format!("{key}={pattern}"),
109 None => format!("{key}=<unsupported>"),
110 })
111 .collect::<Vec<_>>()
112 .join(", ");
113 parts.push(format!("? = {{{rendered}}}"));
114 }
115 None => {}
116 }
117 if let Some(fragment) = &self.fragment {
118 parts.push(format!("# = {fragment}"));
119 }
120 if parts.is_empty() {
121 parts.push("<matches every URL>".to_owned());
122 }
123 if self.exclude {
124 parts.push("exclude".to_owned());
125 }
126 parts.push(format!("caseSensitive={}", self.case_sensitive));
127 parts.push(format!("percentEncoded={}", self.percent_encoded));
128 if self.legacy {
129 parts.push("legacy".to_owned());
130 }
131 f.write_str(&parts.join(", "))
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub(crate) enum CompiledQuery {
137 Whole(Pattern),
138 Items(Vec<(String, Pattern)>),
139 IgnoredDictionary(Vec<String>),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub(crate) struct CompiledPath {
150 pub(crate) pattern: Pattern,
151 pub(crate) parent: Option<Pattern>,
153 pub(crate) try_bare_path: bool,
156}
157
158impl CompiledPath {
159 pub(crate) fn matches(&self, trimmed: &str, bare: Option<&str>, case_sensitive: bool) -> bool {
161 for pattern in std::iter::once(&self.pattern).chain(self.parent.as_ref()) {
162 if pattern.matches_with(trimmed, case_sensitive) {
163 return true;
164 }
165 if self.try_bare_path {
166 if let Some(bare) = bare {
167 if pattern.matches_with(bare, case_sensitive) {
168 return true;
169 }
170 }
171 }
172 }
173 false
174 }
175
176 pub(crate) fn source(&self) -> &str {
177 self.pattern.source()
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub(crate) struct CompiledRule {
183 pub(crate) detail_index: usize,
184 pub(crate) rule_index: usize,
185 pub(crate) legacy: bool,
186 pub(crate) exclude: bool,
187 pub(crate) effective: EffectiveDefaults,
188 pub(crate) path: Option<CompiledPath>,
189 pub(crate) query: Option<CompiledQuery>,
190 pub(crate) fragment: Option<Pattern>,
191 pub(crate) comment: Option<String>,
192}
193
194impl CompiledRule {
195 pub(crate) fn is_unconstrained(&self) -> bool {
197 let path_any = self
198 .path
199 .as_ref()
200 .map_or(true, |path| path.pattern.is_any());
201 let fragment_any = self.fragment.as_ref().map_or(true, Pattern::is_any);
202 let query_any = match &self.query {
203 None | Some(CompiledQuery::IgnoredDictionary(_)) => true,
204 Some(CompiledQuery::Whole(pattern)) => pattern.is_any(),
205 Some(CompiledQuery::Items(items)) => items.is_empty(),
206 };
207 path_any && fragment_any && query_any
208 }
209
210 pub(crate) fn effective_rule(&self) -> EffectiveRule {
211 let query = self.query.as_ref().map(|query| match query {
212 CompiledQuery::Whole(pattern) => EffectiveQuery::Whole(pattern.source().to_owned()),
213 CompiledQuery::Items(items) => EffectiveQuery::Items(
214 items
215 .iter()
216 .map(|(key, pattern)| (key.clone(), Some(pattern.source().to_owned())))
217 .collect(),
218 ),
219 CompiledQuery::IgnoredDictionary(keys) => {
220 EffectiveQuery::Items(keys.iter().map(|key| (key.clone(), None)).collect())
221 }
222 });
223 let path = self.path.as_ref().map(|path| path.source().to_owned());
224 let fragment = self.fragment.as_ref().map(|p| p.source().to_owned());
225 let unconstrained = self.is_unconstrained();
228 EffectiveRule {
229 exclude: self.exclude,
230 legacy: self.legacy,
231 path,
232 query,
233 fragment,
234 case_sensitive: !unconstrained && self.effective.case_sensitive,
235 percent_encoded: !unconstrained && self.effective.percent_encoded,
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub(crate) struct CompiledDetail {
242 pub(crate) index: usize,
243 pub(crate) app_ids: Vec<String>,
244 pub(crate) rules: Vec<CompiledRule>,
245}
246
247impl CompiledDetail {
248 pub(crate) fn applies_to(&self, app_id: &str) -> bool {
249 self.app_ids.iter().any(|candidate| candidate == app_id)
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct CompiledAasa {
256 pub(crate) document: AasaDocument,
257 pub(crate) details: Vec<CompiledDetail>,
258 pub(crate) has_applinks: bool,
259 pub(crate) webcredentials: Vec<String>,
260 pub(crate) appclips: Vec<String>,
261 pub(crate) activitycontinuation: Vec<String>,
262 pub(crate) substitution_variables: BTreeMap<String, Vec<String>>,
263 pub(crate) compile_diagnostics: Vec<Diagnostic>,
264 pub(crate) needs_decoded: bool,
267 pub(crate) needs_query_items: bool,
269}
270
271impl CompiledAasa {
272 pub fn parse(bytes: &[u8]) -> Result<Self, ParseError> {
278 Ok(AasaDocument::parse(bytes)?.compile())
279 }
280
281 pub fn parse_with(bytes: &[u8], options: &ParseOptions) -> Result<Self, ParseError> {
287 Ok(AasaDocument::parse_with(bytes, options)?.compile())
288 }
289
290 #[must_use]
292 pub fn document(&self) -> &AasaDocument {
293 &self.document
294 }
295
296 #[must_use]
298 pub fn has_applinks(&self) -> bool {
299 self.has_applinks
300 }
301
302 #[must_use]
304 pub fn applink_apps(&self) -> Vec<&str> {
305 let set: BTreeSet<&str> = self
306 .details
307 .iter()
308 .flat_map(|detail| detail.app_ids.iter().map(String::as_str))
309 .collect();
310 set.into_iter().collect()
311 }
312
313 #[must_use]
315 pub fn has_applink_app(&self, app_id: &str) -> bool {
316 self.details.iter().any(|detail| detail.applies_to(app_id))
317 }
318
319 #[must_use]
321 pub fn has_webcredential_app(&self, app_id: &str) -> bool {
322 self.webcredentials.iter().any(|entry| entry == app_id)
323 }
324
325 #[must_use]
327 pub fn has_appclip(&self, app_id: &str) -> bool {
328 self.appclips.iter().any(|entry| entry == app_id)
329 }
330
331 #[must_use]
333 pub fn has_activitycontinuation_app(&self, app_id: &str) -> bool {
334 self.activitycontinuation
335 .iter()
336 .any(|entry| entry == app_id)
337 }
338
339 #[must_use]
344 pub fn services_for_bundle(&self, team_id: &str, bundle_id: &str) -> Vec<Service> {
345 self.services_for_app(&format!("{team_id}.{bundle_id}"))
346 }
347
348 #[must_use]
354 pub fn app_ids_for_bundle(&self, bundle_id: &str) -> Vec<&str> {
355 let mut found: Vec<&str> = Vec::new();
356 for service in SERVICES {
357 for app_id in self.apps_for_service(service) {
358 if crate::split_app_id(app_id).is_some_and(|(_, bundle)| bundle == bundle_id)
359 && !found.contains(&app_id)
360 {
361 found.push(app_id);
362 }
363 }
364 }
365 found.sort_unstable();
366 found
367 }
368
369 #[must_use]
371 pub fn services_for_app(&self, app_id: &str) -> Vec<Service> {
372 let mut services = Vec::new();
373 if self.has_applink_app(app_id) {
374 services.push(Service::AppLinks);
375 }
376 if self.has_webcredential_app(app_id) {
377 services.push(Service::WebCredentials);
378 }
379 if self.has_appclip(app_id) {
380 services.push(Service::AppClips);
381 }
382 if self.has_activitycontinuation_app(app_id) {
383 services.push(Service::ActivityContinuation);
384 }
385 services
386 }
387
388 #[must_use]
390 pub fn apps_for_service(&self, service: Service) -> Vec<&str> {
391 match service {
392 Service::AppLinks => self.applink_apps(),
393 Service::WebCredentials => self.webcredentials.iter().map(String::as_str).collect(),
394 Service::AppClips => self.appclips.iter().map(String::as_str).collect(),
395 Service::ActivityContinuation => self
396 .activitycontinuation
397 .iter()
398 .map(String::as_str)
399 .collect(),
400 }
401 }
402
403 #[must_use]
405 pub fn effective_rules_for(&self, app_id: &str) -> Vec<EffectiveRule> {
406 self.details
407 .iter()
408 .filter(|detail| detail.applies_to(app_id))
409 .flat_map(|detail| detail.rules.iter().map(CompiledRule::effective_rule))
410 .collect()
411 }
412
413 #[must_use]
415 pub fn substitution_variables(&self) -> &BTreeMap<String, Vec<String>> {
416 &self.substitution_variables
417 }
418
419 #[must_use]
421 pub fn validate(&self) -> ValidationReport {
422 let mut diagnostics = self.document.structural.clone();
423 diagnostics.extend(self.compile_diagnostics.iter().cloned());
424 diagnostics.extend(crate::validate::semantic(self));
425 ValidationReport::from_diagnostics(diagnostics)
426 }
427}
428
429pub(crate) fn compile(document: &AasaDocument) -> CompiledAasa {
430 let mut diagnostics = Vec::new();
431 let mut details = Vec::new();
432 let mut substitution_variables = BTreeMap::new();
433 let has_applinks = document.applinks.is_some();
434
435 if let Some(applinks) = &document.applinks {
436 substitution_variables.clone_from(&applinks.substitution_variables);
437 let table = build_table(applinks, &mut diagnostics);
438 for (index, detail) in applinks.details.iter().enumerate() {
439 details.push(compile_detail(
440 applinks,
441 detail,
442 index,
443 &table,
444 &mut diagnostics,
445 ));
446 }
447 }
448
449 let needs_decoded = details
450 .iter()
451 .flat_map(|detail| detail.rules.iter())
452 .any(|rule| !rule.effective.percent_encoded);
453 let needs_query_items = details
454 .iter()
455 .flat_map(|detail| detail.rules.iter())
456 .any(|rule| matches!(rule.query, Some(CompiledQuery::Items(_))));
457
458 CompiledAasa {
459 document: document.clone(),
460 details,
461 has_applinks,
462 webcredentials: service_apps(document.webcredentials.as_ref()),
463 appclips: service_apps(document.appclips.as_ref()),
464 activitycontinuation: service_apps(document.activitycontinuation.as_ref()),
465 substitution_variables,
466 compile_diagnostics: diagnostics,
467 needs_decoded,
468 needs_query_items,
469 }
470}
471
472fn service_apps(service: Option<&crate::model::AppService>) -> Vec<String> {
473 service
474 .map(|service| service.apps.clone())
475 .unwrap_or_default()
476}
477
478fn build_table(applinks: &AppLinks, diagnostics: &mut Vec<Diagnostic>) -> SubstitutionTable {
479 for (name, values) in &applinks.substitution_variables {
480 let path = format!("applinks.substitutionVariables.{name}");
481 if name.contains(['$', '(', ')']) {
482 diagnostics.push(
483 Diagnostic::new(
484 DiagnosticCode::MalformedSubstitutionName,
485 &path,
486 format!("`{name}` contains $, ( or ), which Apple does not allow in a variable name"),
487 )
488 .with_help("rename the variable using only characters outside $ ( )"),
489 );
490 }
491 if SubstitutionTable::is_predefined(name) {
492 diagnostics.push(
493 Diagnostic::new(
494 DiagnosticCode::SubstitutionShadowsPredefined,
495 &path,
496 format!("`{name}` shadows the predefined $({name}) variable"),
497 )
498 .with_help("this crate honours your definition; rename it to avoid ambiguity"),
499 );
500 }
501 if values.is_empty() {
502 diagnostics.push(
503 Diagnostic::new(
504 DiagnosticCode::EmptySubstitutionList,
505 &path,
506 format!("`{name}` has no values, so any pattern using it can never match"),
507 )
508 .with_help("remove the variable or give it at least one value"),
509 );
510 }
511 for (index, value) in values.iter().enumerate() {
512 if value.contains("$(") {
513 diagnostics.push(
514 Diagnostic::new(
515 DiagnosticCode::RecursiveSubstitutionValue,
516 format!("{path}[{index}]"),
517 format!(
518 "`{value}` references another substitution variable, which Apple does \
519 not allow"
520 ),
521 )
522 .with_help("inline the referenced values instead"),
523 );
524 }
525 if value.is_empty() {
526 diagnostics.push(Diagnostic::new(
527 DiagnosticCode::EmptyPatternAlternative,
528 format!("{path}[{index}]"),
529 format!("`{name}` contains an empty alternative"),
530 ));
531 }
532 }
533 }
534 SubstitutionTable::from_custom(applinks.substitution_variables.clone())
535}
536
537fn compile_detail(
538 applinks: &AppLinks,
539 detail: &AppLinkDetail,
540 index: usize,
541 table: &SubstitutionTable,
542 diagnostics: &mut Vec<Diagnostic>,
543) -> CompiledDetail {
544 let base = EffectiveDefaults::default()
545 .overridden_by(applinks.defaults.as_ref())
546 .overridden_by(detail.defaults.as_ref());
547
548 let mut app_ids: Vec<String> = Vec::new();
549 for app_id in detail.declared_app_ids() {
550 if !app_ids.iter().any(|existing| existing == app_id) {
551 app_ids.push(app_id.to_owned());
552 }
553 }
554
555 let mut rules = Vec::new();
556 let detail_path = || format!("applinks.details[{index}]");
557
558 if let Some(components) = &detail.components {
559 for (rule_index, component) in components.iter().enumerate() {
560 rules.push(compile_component(
561 component,
562 index,
563 rule_index,
564 base,
565 table,
566 &|| format!("{}.components[{rule_index}]", detail_path()),
567 diagnostics,
568 ));
569 }
570 }
571
572 if let Some(paths) = &detail.paths {
573 let offset = rules.len();
574 for (position, path) in paths.iter().enumerate() {
575 let rule_index = offset + position;
576 rules.push(compile_legacy_path(
577 path,
578 index,
579 rule_index,
580 base,
581 table,
582 &|| format!("{}.paths[{position}]", detail_path()),
583 diagnostics,
584 ));
585 }
586 }
587
588 CompiledDetail {
589 index,
590 app_ids,
591 rules,
592 }
593}
594
595type PathFn<'a> = &'a dyn Fn() -> String;
597
598fn compile_component(
599 component: &ComponentRule,
600 detail_index: usize,
601 rule_index: usize,
602 base: EffectiveDefaults,
603 table: &SubstitutionTable,
604 path: PathFn<'_>,
605 diagnostics: &mut Vec<Diagnostic>,
606) -> CompiledRule {
607 let mut effective = base;
608 if let Some(case_sensitive) = component.case_sensitive {
609 effective.case_sensitive = case_sensitive;
610 }
611 if let Some(percent_encoded) = component.percent_encoded {
612 effective.percent_encoded = percent_encoded;
613 }
614
615 let compiled_path = component.path.as_ref().map(|pattern| {
618 compile_path(
619 pattern,
620 effective.case_sensitive,
621 table,
622 &|| format!("{}./", path()),
623 diagnostics,
624 )
625 });
626
627 let compiled_fragment = component.fragment.as_ref().map(|pattern| {
628 compile_pattern(
629 pattern,
630 effective.case_sensitive,
631 table,
632 &|| format!("{}.#", path()),
633 diagnostics,
634 )
635 });
636
637 let compiled_query = component.query.as_ref().map(|query| match query {
638 QueryRule::Whole(pattern) => CompiledQuery::Whole(compile_pattern(
639 pattern,
640 effective.case_sensitive,
641 table,
642 &|| format!("{}.?", path()),
643 diagnostics,
644 )),
645 QueryRule::Items(items) => {
646 if items
647 .values()
648 .any(|predicate| matches!(predicate, QueryPredicate::Unsupported { .. }))
649 {
650 CompiledQuery::IgnoredDictionary(items.keys().cloned().collect())
651 } else {
652 CompiledQuery::Items(
653 items
654 .iter()
655 .filter_map(|(key, predicate)| match predicate {
656 QueryPredicate::Pattern(pattern) => Some((
657 key.clone(),
658 compile_pattern(
659 pattern,
660 effective.case_sensitive,
661 table,
662 &|| format!("{}.?.{key}", path()),
663 diagnostics,
664 ),
665 )),
666 QueryPredicate::Unsupported { .. } => None,
667 })
668 .collect(),
669 )
670 }
671 }
672 });
673
674 CompiledRule {
675 detail_index,
676 rule_index,
677 legacy: false,
678 exclude: component.exclude.unwrap_or(false),
679 effective,
680 path: compiled_path,
681 query: compiled_query,
682 fragment: compiled_fragment,
683 comment: component.comment.clone(),
684 }
685}
686
687fn compile_legacy_path(
688 source: &str,
689 detail_index: usize,
690 rule_index: usize,
691 base: EffectiveDefaults,
692 table: &SubstitutionTable,
693 path: PathFn<'_>,
694 diagnostics: &mut Vec<Diagnostic>,
695) -> CompiledRule {
696 let (exclude, pattern_source) = match source.strip_prefix("NOT ") {
697 Some(rest) => (true, rest.trim_start()),
698 None => (false, source),
699 };
700 let compiled = compile_path(
701 pattern_source,
702 base.case_sensitive,
703 table,
704 path,
705 diagnostics,
706 );
707 CompiledRule {
708 detail_index,
709 rule_index,
710 legacy: true,
711 exclude,
712 effective: base,
713 path: Some(compiled),
714 query: None,
715 fragment: None,
716 comment: None,
717 }
718}
719
720fn compile_path(
722 source: &str,
723 case_sensitive: bool,
724 table: &SubstitutionTable,
725 path: PathFn<'_>,
726 diagnostics: &mut Vec<Diagnostic>,
727) -> CompiledPath {
728 let (canonical, shape) = crate::url::normalize_path_pattern(source);
729 let pattern = compile_pattern(&canonical, case_sensitive, table, path, diagnostics);
730 let parent = shape.matches_parent.then(|| {
731 let parent = canonical
732 .strip_suffix("/*")
733 .expect("matches_parent implies a trailing /*");
734 compile_pattern(parent, case_sensitive, table, path, diagnostics)
735 });
736 CompiledPath {
737 pattern,
738 parent,
739 try_bare_path: !shape.canonical_leading_slash,
740 }
741}
742
743fn compile_pattern(
744 source: &str,
745 case_sensitive: bool,
746 table: &SubstitutionTable,
747 path: PathFn<'_>,
748 diagnostics: &mut Vec<Diagnostic>,
749) -> Pattern {
750 let mut errors = Vec::new();
751 let pattern = Pattern::compile(source, case_sensitive, table, &mut errors);
752 for error in errors {
753 diagnostics.push(match error {
754 PatternError::UnterminatedReference => Diagnostic::new(
755 DiagnosticCode::UnterminatedSubstitutionReference,
756 path(),
757 format!("`{source}` contains a `$(` that is never closed"),
758 )
759 .with_help("close the reference with `)`"),
760 PatternError::UnknownVariable(name) => Diagnostic::new(
761 DiagnosticCode::UnknownSubstitutionVariable,
762 path(),
763 format!("`$({name})` is neither a predefined variable nor declared in substitutionVariables"),
764 )
765 .with_help("declare it under applinks.substitutionVariables, or fix the spelling"),
766 PatternError::NestedSubstitution { variable, value } => Diagnostic::new(
767 DiagnosticCode::RecursiveSubstitutionValue,
768 path(),
769 format!("substitution value `{value}` references `$({variable})`"),
770 ),
771 PatternError::EmptyVariable(name) => Diagnostic::new(
772 DiagnosticCode::EmptySubstitutionList,
773 path(),
774 format!("`$({name})` has no values, so this pattern can never match"),
775 ),
776 });
777 }
778 pattern
779}