Skip to main content

asc_engine/
manifest.rs

1//! Structured `AndroidManifest.xml` summary: the attack surface an audit
2//! starts from (exported components, permissions, providers, deep links,
3//! application flags, network security config).
4//!
5//! Every attribute that is a resource reference is resolved through
6//! `resources.arsc` as a device on the newest SDK with no locale would see it
7//! ([`asc_arsc::Config::any_device`]); when a referenced boolean differs
8//! between configurations (e.g. `exported="@bool/x"` with a `values-v31`
9//! override) every variant is reported next to the value.
10
11use crate::resources::{plain_value, resolve_simple};
12use crate::text::value;
13use crate::{Engine, Result};
14use asc_arsc::{EntryValue, ResTable, TYPE_INT_BOOLEAN, TYPE_STRING};
15use asc_axml::{Attribute, Element};
16use serde_json::{Value as Json, json};
17
18/// Android 12 (API 31) requires an explicit `android:exported` on every
19/// component with an intent filter; before it such components were exported.
20const SDK_EXPLICIT_EXPORTED: i64 = 31;
21/// Before API 17 providers were exported unless they said otherwise.
22const SDK_PROVIDER_PRIVATE: i64 = 17;
23/// From API 28 cleartext traffic is off unless enabled.
24const SDK_CLEARTEXT_OFF: i64 = 28;
25const FILE_PROVIDER_PATHS: &str = "android.support.FILE_PROVIDER_PATHS";
26
27/// What [`Engine::manifest_summary`] extracts from `AndroidManifest.xml`.
28#[derive(Debug, Clone, Default, PartialEq)]
29#[non_exhaustive]
30pub struct ManifestSummary {
31    /// The `package` attribute.
32    pub package: String,
33    /// `android:versionCode`, as written.
34    pub version_code: Option<String>,
35    /// `android:versionName`.
36    pub version_name: Option<String>,
37    /// `<uses-sdk android:minSdkVersion>`.
38    pub min_sdk: Option<i64>,
39    /// `<uses-sdk android:targetSdkVersion>`.
40    pub target_sdk: Option<i64>,
41    /// `android:sharedUserId`.
42    pub shared_user_id: Option<String>,
43    /// The `<application>` element.
44    pub application: Application,
45    /// Names of the `<uses-permission>` elements.
46    pub uses_permissions: Vec<String>,
47    /// Permissions the app declares (`<permission>`).
48    pub permissions: Vec<Permission>,
49    /// Activities, aliases, services, receivers and providers, in manifest order.
50    pub components: Vec<Component>,
51    /// Decoded `networkSecurityConfig`, one record per line.
52    pub network_security: Vec<String>,
53    /// Attributes that could not be resolved and similar notes.
54    pub warnings: Vec<String>,
55}
56
57/// The `<application>` element, with Android's defaults applied.
58#[derive(Debug, Clone, Default, PartialEq)]
59#[non_exhaustive]
60pub struct Application {
61    /// The `Application` subclass, fully qualified.
62    pub class: Option<String>,
63    /// `android:debuggable` (default false).
64    pub debuggable: bool,
65    /// `android:allowBackup` (default true).
66    pub allow_backup: bool,
67    /// `android:testOnly` (default false).
68    pub test_only: bool,
69    /// `android:usesCleartextTraffic`, when set.
70    pub uses_cleartext_traffic: Option<bool>,
71    /// Whether cleartext HTTP is allowed by default, and what decided it.
72    pub cleartext_effective: (bool, &'static str),
73    /// Path of the network security config XML (`res/xml/...`).
74    pub network_security_config: Option<String>,
75    /// `android:permission` guarding every component without its own.
76    pub permission: Option<String>,
77    /// `android:fullBackupContent`: the XML path, or the raw value.
78    pub full_backup_content: Option<String>,
79    /// `android:dataExtractionRules`: the XML path, or the raw value.
80    pub data_extraction_rules: Option<String>,
81}
82
83/// A `<permission>` the app declares.
84#[derive(Debug, Clone, Default, PartialEq)]
85#[non_exhaustive]
86pub struct Permission {
87    /// The permission name.
88    pub name: String,
89    /// `|`-joined level and flags, e.g. `signature|privileged`.
90    pub protection_level: String,
91}
92
93/// An activity, activity alias, service, receiver or provider.
94#[derive(Debug, Clone, Default, PartialEq)]
95#[non_exhaustive]
96pub struct Component {
97    /// `activity`, `activity-alias`, `service`, `receiver` or `provider`.
98    pub kind: String,
99    /// Fully qualified class name (for an alias: the alias name).
100    pub class: String,
101    /// `targetActivity` of an alias, fully qualified.
102    pub target: Option<String>,
103    /// Whether other apps can reach it, after Android's defaults.
104    pub exported: bool,
105    /// `explicit`, `intent-filter` (implicit, targetSdk < 31), `provider-default`
106    /// (targetSdk < 17) or `default`, plus `@ref` variants when the value differs
107    /// between configurations.
108    pub exported_reason: String,
109    /// `android:enabled` (default true).
110    pub enabled: bool,
111    /// Own permission, or the application's when the component sets none.
112    pub permission: Option<String>,
113    /// `permission` was inherited from the application.
114    pub permission_from_app: bool,
115    /// `android:process`.
116    pub process: Option<String>,
117    /// Provider authorities (split on `;`).
118    pub authorities: Vec<String>,
119    /// Provider `android:grantUriPermissions` (default false).
120    pub grant_uri_permissions: bool,
121    /// Provider `android:readPermission`.
122    pub read_permission: Option<String>,
123    /// Provider `android:writePermission`.
124    pub write_permission: Option<String>,
125    /// The component's `<intent-filter>`s.
126    pub intent_filters: Vec<IntentFilter>,
127    /// `<meta-data>` as `(name, value or resource)`.
128    pub meta_data: Vec<(String, String)>,
129    /// FileProvider path entries (`files-path name=x path=y`).
130    pub provider_paths: Vec<String>,
131}
132
133/// An `<intent-filter>`.
134#[derive(Debug, Clone, Default, PartialEq)]
135#[non_exhaustive]
136pub struct IntentFilter {
137    /// `<action>` names.
138    pub actions: Vec<String>,
139    /// `<category>` names.
140    pub categories: Vec<String>,
141    /// `<data>` elements.
142    pub data: Vec<DataSpec>,
143}
144
145/// A `<data>` element of an intent filter.
146#[derive(Debug, Clone, Default, PartialEq)]
147#[non_exhaustive]
148pub struct DataSpec {
149    /// `android:scheme`.
150    pub scheme: Option<String>,
151    /// `android:host`.
152    pub host: Option<String>,
153    /// `android:port`.
154    pub port: Option<String>,
155    /// `path`, `pathPrefix`, `pathPattern`, `pathAdvancedPattern` or
156    /// `pathSuffix`, with its value.
157    pub path: Option<(String, String)>,
158    /// `android:mimeType`.
159    pub mime_type: Option<String>,
160}
161
162impl IntentFilter {
163    /// Deep link URL patterns of a `VIEW` filter: every scheme combined with
164    /// every host and path, as Android matches them (`*` for absent parts).
165    pub fn deep_links(&self) -> Vec<String> {
166        if !self.actions.iter().any(|a| a == "android.intent.action.VIEW") {
167            return Vec::new();
168        }
169        let pick = |f: fn(&DataSpec) -> Option<String>| -> Vec<String> {
170            let mut v: Vec<String> = self.data.iter().filter_map(f).collect();
171            v.dedup();
172            v
173        };
174        let schemes = pick(|d| d.scheme.clone());
175        let hosts = pick(|d| {
176            d.host.clone().map(|h| match &d.port {
177                Some(p) => format!("{h}:{p}"),
178                None => h,
179            })
180        });
181        let paths = pick(|d| d.path.as_ref().map(|(k, v)| if k == "pathPrefix" { format!("{v}*") } else { v.clone() }));
182        let mut out = Vec::new();
183        for s in &schemes {
184            if !matches!(s.as_str(), "http" | "https") && hosts.is_empty() {
185                out.push(format!("{s}://*"));
186                continue;
187            }
188            for h in if hosts.is_empty() { vec!["*".to_string()] } else { hosts.clone() } {
189                if paths.is_empty() {
190                    out.push(format!("{s}://{h}"));
191                }
192                for p in &paths {
193                    let sep = if p.starts_with('/') { "" } else { "/" };
194                    out.push(format!("{s}://{h}{sep}{p}"));
195                }
196            }
197        }
198        out
199    }
200
201    fn line(&self) -> String {
202        let list = |vs: &[String]| vs.iter().map(|v| value(v)).collect::<Vec<_>>().join(",");
203        let mut parts = Vec::new();
204        if !self.actions.is_empty() {
205            parts.push(format!("action={}", list(&self.actions)));
206        }
207        if !self.categories.is_empty() {
208            parts.push(format!("category={}", list(&self.categories)));
209        }
210        for d in &self.data {
211            let mut s = String::new();
212            if let Some(v) = &d.scheme {
213                s.push_str(&format!("scheme={} ", value(v)));
214            }
215            if let Some(v) = &d.host {
216                s.push_str(&format!("host={} ", value(v)));
217            }
218            if let Some(v) = &d.port {
219                s.push_str(&format!("port={} ", value(v)));
220            }
221            if let Some((k, v)) = &d.path {
222                s.push_str(&format!("{k}={} ", value(v)));
223            }
224            if let Some(v) = &d.mime_type {
225                s.push_str(&format!("mimeType={} ", value(v)));
226            }
227            parts.push(format!("data({})", s.trim_end()));
228        }
229        parts.join(" ")
230    }
231
232    fn json(&self) -> Json {
233        json!({
234            "actions": self.actions,
235            "categories": self.categories,
236            "data": self.data.iter().map(|d| json!({
237                "scheme": d.scheme, "host": d.host, "port": d.port,
238                "path_kind": d.path.as_ref().map(|p| &p.0), "path": d.path.as_ref().map(|p| &p.1),
239                "mime_type": d.mime_type,
240            })).collect::<Vec<_>>(),
241        })
242    }
243}
244
245/// `android:protectionLevel` flags as text (`signature|privileged`).
246pub(crate) fn protection_level_name(level: u32) -> String {
247    let base = match level & 0xf {
248        0 => "normal".to_string(),
249        1 => "dangerous".to_string(),
250        2 => "signature".to_string(),
251        3 => "signatureOrSystem".to_string(),
252        4 => "internal".to_string(),
253        b => format!("base{b}"),
254    };
255    const FLAGS: [(u32, &str); 18] = [
256        (0x10, "privileged"),
257        (0x20, "development"),
258        (0x40, "appop"),
259        (0x80, "pre23"),
260        (0x100, "installer"),
261        (0x200, "verifier"),
262        (0x400, "preinstalled"),
263        (0x800, "setup"),
264        (0x1000, "instant"),
265        (0x2000, "runtime"),
266        (0x4000, "oem"),
267        (0x8000, "vendorPrivileged"),
268        (0x10000, "textClassifier"),
269        (0x20000, "configurator"),
270        (0x40000, "incidentReportApprover"),
271        (0x80000, "appPredictor"),
272        (0x100000, "module"),
273        (0x200000, "companion"),
274    ];
275    let mut out = base;
276    let mut rest = level & !0xf;
277    for (bit, name) in FLAGS {
278        if rest & bit != 0 {
279            out.push('|');
280            out.push_str(name);
281            rest &= !bit;
282        }
283    }
284    if rest != 0 {
285        out.push_str(&format!("|0x{rest:x}"));
286    }
287    out
288}
289
290/// Attribute reading with references resolved through the resource table.
291struct Reader<'a> {
292    table: Option<&'a ResTable>,
293    warnings: Vec<String>,
294}
295
296impl Reader<'_> {
297    /// String value of `a`: literal strings as-is, references resolved to
298    /// their (default-config) value.
299    fn string(&mut self, a: &Attribute) -> String {
300        if let (Some(id), Some(t)) = (a.reference(), self.table) {
301            match resolve_simple(t, id) {
302                Some(v) => return plain_value(t, &v),
303                None => self.warnings.push(format!("{}={} does not resolve to a value", a.name, a.value)),
304            }
305        }
306        a.value.clone()
307    }
308
309    fn str_attr(&mut self, el: &Element, name: &str) -> Option<String> {
310        el.android(name).map(|a| self.string(a))
311    }
312
313    /// Boolean attribute, plus a note listing per-config values when it is a
314    /// reference whose value differs between configurations.
315    fn bool_attr(&mut self, el: &Element, name: &str) -> Option<(bool, Option<String>)> {
316        let a = el.android(name)?;
317        if let Some(b) = a.as_bool() {
318            return Some((b, None));
319        }
320        let (Some(id), Some(t)) = (a.reference(), self.table) else {
321            self.warnings.push(format!("{name}={} is not a boolean", a.value));
322            return None;
323        };
324        let variants: Vec<(String, bool)> = t
325            .values(id)
326            .into_iter()
327            .filter_map(|v| match v.value {
328                EntryValue::Simple(x) if x.kind == TYPE_INT_BOOLEAN => Some((v.config.to_string(), x.data != 0)),
329                EntryValue::Simple(x) if x.kind == TYPE_STRING => {
330                    Some((v.config.to_string(), t.string(x.data).is_some_and(|s| s == "true")))
331                }
332                _ => None,
333            })
334            .collect();
335        let resolved = resolve_simple(t, id).and_then(|v| match v.kind {
336            TYPE_INT_BOOLEAN => Some(v.data != 0),
337            TYPE_STRING => t.string(v.data).map(|s| s == "true"),
338            _ => None,
339        });
340        let Some(b) = resolved else {
341            self.warnings.push(format!("{name}={} does not resolve to a boolean", a.value));
342            return None;
343        };
344        let differs = variants.iter().any(|(_, v)| *v != variants[0].1);
345        let note = differs.then(|| {
346            let list: Vec<String> = variants.iter().map(|(c, v)| format!("{}={v}", if c.is_empty() { "default" } else { c })).collect();
347            format!("{} varies: {}", a.value, list.join(","))
348        });
349        Some((b, note))
350    }
351
352    fn int_attr(&mut self, el: &Element, name: &str) -> Option<i64> {
353        let a = el.android(name)?;
354        if let Some(i) = a.as_int() {
355            return Some(i);
356        }
357        if let (Some(id), Some(t)) = (a.reference(), self.table)
358            && let Some(v) = resolve_simple(t, id)
359        {
360            return match v.kind {
361                TYPE_STRING => t.string(v.data).and_then(|s| s.trim().parse().ok()),
362                _ => Some(v.data as i32 as i64),
363            };
364        }
365        // A codename such as "Tiramisu" in a preview build.
366        a.value.parse().ok()
367    }
368
369    /// Path in the APK of the XML resource an attribute points at.
370    fn xml_path(&mut self, a: &Attribute) -> Option<String> {
371        let (id, t) = (a.reference()?, self.table?);
372        match resolve_simple(t, id) {
373            Some(v) if v.kind == TYPE_STRING => t.string(v.data).map(|s| s.into_owned()),
374            _ => {
375                self.warnings.push(format!("{}={} is not a file resource", a.name, a.value));
376                None
377            }
378        }
379    }
380}
381
382/// `.Foo` / `Foo` / `com.x.Foo` → fully qualified against `package`.
383fn class_name(package: &str, name: &str) -> String {
384    if let Some(rest) = name.strip_prefix('.') {
385        format!("{package}.{rest}")
386    } else if !name.contains('.') && !package.is_empty() {
387        format!("{package}.{name}")
388    } else {
389        name.to_string()
390    }
391}
392
393impl Engine {
394    /// Parse `AndroidManifest.xml` (and the XML resources it points at) into
395    /// a [`ManifestSummary`].
396    ///
397    /// # Errors
398    ///
399    /// [`EngineError::Apk`](crate::EngineError::Apk) when the APK or manifest
400    /// cannot be read, [`EngineError::Arsc`](crate::EngineError::Arsc) when
401    /// `resources.arsc` is malformed, and
402    /// [`EngineError::Axml`](crate::EngineError::Axml) when the manifest is not
403    /// valid binary XML. Referenced XML resources that fail are
404    /// [`ManifestSummary::warnings`] instead.
405    pub fn manifest_summary(&self) -> Result<ManifestSummary> {
406        let apk = self.open()?;
407        let data = apk.read("AndroidManifest.xml")?;
408        let table = self.resources()?;
409        let resolver = table.as_deref().map(|t| t as &dyn asc_axml::Resolver);
410        let root = asc_axml::parse_tree(&data, resolver)?;
411        let mut r = Reader { table: table.as_deref(), warnings: Vec::new() };
412        let mut s = ManifestSummary {
413            package: root.plain("package").map(|a| a.value.clone()).unwrap_or_default(),
414            version_code: r.str_attr(&root, "versionCode"),
415            version_name: r.str_attr(&root, "versionName"),
416            shared_user_id: r.str_attr(&root, "sharedUserId"),
417            ..ManifestSummary::default()
418        };
419        if table.is_none() {
420            s.warnings.push("no resources.arsc: references are not resolved".into());
421        }
422        for sdk in root.children_named("uses-sdk") {
423            s.min_sdk = r.int_attr(sdk, "minSdkVersion").or(s.min_sdk);
424            s.target_sdk = r.int_attr(sdk, "targetSdkVersion").or(s.target_sdk);
425        }
426        // Without a targetSdkVersion Android uses minSdkVersion (default 1).
427        let target = s.target_sdk.or(s.min_sdk).unwrap_or(1);
428        for el in &root.children {
429            match el.name.as_str() {
430                "uses-permission" | "uses-permission-sdk-23" | "uses-permission-sdk-m" => {
431                    if let Some(n) = r.str_attr(el, "name") {
432                        s.uses_permissions.push(n);
433                    }
434                }
435                "permission" => {
436                    let level = el.android("protectionLevel").map_or(0, |a| a.data);
437                    s.permissions.push(Permission {
438                        name: r.str_attr(el, "name").unwrap_or_default(),
439                        protection_level: protection_level_name(level),
440                    });
441                }
442                _ => {}
443            }
444        }
445        if let Some(app) = root.children_named("application").next() {
446            let a = &mut s.application;
447            a.class = r.str_attr(app, "name").map(|n| class_name(&s.package, &n));
448            a.debuggable = r.bool_attr(app, "debuggable").is_some_and(|b| b.0);
449            a.allow_backup = r.bool_attr(app, "allowBackup").is_none_or(|b| b.0);
450            a.test_only = r.bool_attr(app, "testOnly").is_some_and(|b| b.0);
451            a.uses_cleartext_traffic = r.bool_attr(app, "usesCleartextTraffic").map(|b| b.0);
452            a.permission = r.str_attr(app, "permission");
453            a.full_backup_content = app.android("fullBackupContent").map(|x| r.xml_path(x).unwrap_or_else(|| x.value.clone()));
454            a.data_extraction_rules = app.android("dataExtractionRules").map(|x| r.xml_path(x).unwrap_or_else(|| x.value.clone()));
455            a.network_security_config = app.android("networkSecurityConfig").and_then(|x| r.xml_path(x));
456            let mut nsc_cleartext = None;
457            if let Some(path) = a.network_security_config.clone() {
458                match apk.read(&path).map_err(crate::EngineError::from).and_then(|d| Ok(asc_axml::parse_tree(&d, resolver)?)) {
459                    Ok(doc) => nsc_cleartext = network_security(&doc, table.as_deref(), &mut s.network_security),
460                    Err(e) => r.warnings.push(format!("networkSecurityConfig {path}: {e}")),
461                }
462            }
463            a.cleartext_effective = match (nsc_cleartext, a.uses_cleartext_traffic) {
464                (Some(b), _) => (b, "networkSecurityConfig"),
465                (None, Some(b)) => (b, "usesCleartextTraffic"),
466                (None, None) => (target < SDK_CLEARTEXT_OFF, "targetSdk default"),
467            };
468            let app_permission = a.permission.clone();
469            for el in &app.children {
470                let kind = el.name.as_str();
471                if !matches!(kind, "activity" | "activity-alias" | "service" | "receiver" | "provider") {
472                    continue;
473                }
474                let mut c = Component {
475                    kind: kind.to_string(),
476                    class: class_name(&s.package, &r.str_attr(el, "name").unwrap_or_default()),
477                    target: r.str_attr(el, "targetActivity").map(|n| class_name(&s.package, &n)),
478                    enabled: r.bool_attr(el, "enabled").is_none_or(|b| b.0),
479                    process: r.str_attr(el, "process"),
480                    ..Component::default()
481                };
482                c.permission = r.str_attr(el, "permission");
483                if c.permission.is_none() && app_permission.is_some() {
484                    c.permission = app_permission.clone();
485                    c.permission_from_app = true;
486                }
487                for f in el.children_named("intent-filter") {
488                    let mut filter = IntentFilter::default();
489                    for x in &f.children {
490                        match x.name.as_str() {
491                            "action" => filter.actions.extend(r.str_attr(x, "name")),
492                            "category" => filter.categories.extend(r.str_attr(x, "name")),
493                            "data" => {
494                                let mut d = DataSpec {
495                                    scheme: r.str_attr(x, "scheme"),
496                                    host: r.str_attr(x, "host"),
497                                    port: r.str_attr(x, "port"),
498                                    mime_type: r.str_attr(x, "mimeType"),
499                                    path: None,
500                                };
501                                for k in ["path", "pathPrefix", "pathPattern", "pathAdvancedPattern", "pathSuffix"] {
502                                    if let Some(v) = r.str_attr(x, k) {
503                                        d.path = Some((k.to_string(), v));
504                                    }
505                                }
506                                filter.data.push(d);
507                            }
508                            _ => {}
509                        }
510                    }
511                    c.intent_filters.push(filter);
512                }
513                let (exported, reason) = match r.bool_attr(el, "exported") {
514                    Some((b, note)) => (b, note.map_or("explicit".to_string(), |n| format!("explicit, {n}"))),
515                    None if !c.intent_filters.is_empty() && target < SDK_EXPLICIT_EXPORTED => (true, "intent-filter".to_string()),
516                    None if !c.intent_filters.is_empty() => (false, "missing (install fails on targetSdk>=31)".to_string()),
517                    None if kind == "provider" && target < SDK_PROVIDER_PRIVATE => (true, "provider-default".to_string()),
518                    None => (false, "default".to_string()),
519                };
520                c.exported = exported;
521                c.exported_reason = reason;
522                if kind == "provider" {
523                    c.authorities = r.str_attr(el, "authorities").map(|a| a.split(';').map(str::to_string).collect()).unwrap_or_default();
524                    c.grant_uri_permissions = r.bool_attr(el, "grantUriPermissions").is_some_and(|b| b.0);
525                    c.read_permission = r.str_attr(el, "readPermission");
526                    c.write_permission = r.str_attr(el, "writePermission");
527                }
528                for m in el.children_named("meta-data") {
529                    let name = r.str_attr(m, "name").unwrap_or_default();
530                    let value = m.android("value").or(m.android("resource")).map(|a| r.string(a));
531                    if name == FILE_PROVIDER_PATHS
532                        && let Some(path) = m.android("resource").and_then(|a| r.xml_path(a))
533                    {
534                        match apk.read(&path).map_err(crate::EngineError::from).and_then(|d| Ok(asc_axml::parse_tree(&d, resolver)?)) {
535                            Ok(doc) => c.provider_paths = provider_paths(&doc),
536                            Err(e) => r.warnings.push(format!("{FILE_PROVIDER_PATHS} {path}: {e}")),
537                        }
538                    }
539                    c.meta_data.push((name, value.unwrap_or_default()));
540                }
541                s.components.push(c);
542            }
543        }
544        s.warnings.extend(r.warnings);
545        Ok(s)
546    }
547}
548
549/// Summarize a network security config into `out` records; returns the
550/// base config's cleartext setting when it states one.
551fn network_security(doc: &Element, table: Option<&ResTable>, out: &mut Vec<String>) -> Option<bool> {
552    let cleartext = |el: &Element| el.plain("cleartextTrafficPermitted").and_then(|a| a.as_bool());
553    let anchors = |el: &Element| -> String {
554        let list: Vec<String> = el
555            .children_named("trust-anchors")
556            .flat_map(|t| t.children_named("certificates"))
557            .map(|c| {
558                let src = c.plain("src").map_or(String::new(), |a| match (a.reference(), table) {
559                    (Some(id), Some(t)) => format!("@{}", t.ref_name(id)),
560                    _ => a.value.clone(),
561                });
562                match c.plain("overridePins").and_then(|a| a.as_bool()) {
563                    Some(true) => format!("{src}(overridePins)"),
564                    _ => src,
565                }
566            })
567            .collect();
568        list.join(",")
569    };
570    let mut base_cleartext = None;
571    for el in &doc.children {
572        match el.name.as_str() {
573            "base-config" => {
574                base_cleartext = cleartext(el);
575                out.push(format!("base-config cleartextTrafficPermitted={} trust-anchors={}", fmt_opt(cleartext(el)), anchors(el)));
576            }
577            "debug-overrides" => out.push(format!("debug-overrides trust-anchors={}", anchors(el))),
578            "domain-config" => domain_config(el, &cleartext, &anchors, out),
579            _ => {}
580        }
581    }
582    base_cleartext
583}
584
585fn domain_config(el: &Element, cleartext: &dyn Fn(&Element) -> Option<bool>, anchors: &dyn Fn(&Element) -> String, out: &mut Vec<String>) {
586    let domains: Vec<String> = el
587        .children_named("domain")
588        .map(|d| {
589            let sub = d.plain("includeSubdomains").and_then(|a| a.as_bool()).unwrap_or(false);
590            if sub { format!("*.{}", d.text) } else { d.text.clone() }
591        })
592        .collect();
593    let pins: usize = el.children_named("pin-set").map(|p| p.children_named("pin").count()).sum();
594    out.push(format!(
595        "domain-config domains={} cleartextTrafficPermitted={} trust-anchors={} pins={pins}",
596        domains.join(","),
597        fmt_opt(cleartext(el)),
598        anchors(el)
599    ));
600    for nested in el.children_named("domain-config") {
601        domain_config(nested, cleartext, anchors, out);
602    }
603}
604
605fn fmt_opt(b: Option<bool>) -> String {
606    b.map_or("-".to_string(), |b| b.to_string())
607}
608
609/// `<paths>` children of a FileProvider config.
610fn provider_paths(doc: &Element) -> Vec<String> {
611    doc.children
612        .iter()
613        .map(|p| {
614            let attr = |n: &str| p.plain(n).map_or("-".to_string(), |a| value(&a.value).into_owned());
615            format!("{} name={} path={}", p.name, attr("name"), attr("path"))
616        })
617        .collect()
618}
619
620impl Component {
621    fn line(&self) -> String {
622        let mut s = format!("{} {} exported={} ({})", self.kind, value(&self.class), self.exported, self.exported_reason);
623        if !self.enabled {
624            s.push_str(" enabled=false");
625        }
626        if let Some(t) = &self.target {
627            s.push_str(&format!(" target={}", value(t)));
628        }
629        if let Some(p) = &self.permission {
630            s.push_str(&format!(" permission={}{}", value(p), if self.permission_from_app { "(application)" } else { "" }));
631        }
632        if self.kind == "provider" {
633            s.push_str(&format!(" authorities={} grantUriPermissions={}", self.authorities(), self.grant_uri_permissions));
634            if let Some(p) = &self.read_permission {
635                s.push_str(&format!(" readPermission={}", value(p)));
636            }
637            if let Some(p) = &self.write_permission {
638                s.push_str(&format!(" writePermission={}", value(p)));
639            }
640        }
641        if let Some(p) = &self.process {
642            s.push_str(&format!(" process={}", value(p)));
643        }
644        s
645    }
646
647    /// The provider's authorities, `;`-separated as the manifest writes them.
648    fn authorities(&self) -> String {
649        self.authorities.iter().map(|a| value(a)).collect::<Vec<_>>().join(";")
650    }
651
652    /// The component as a JSON object (snake_case keys).
653    pub fn json(&self) -> Json {
654        json!({
655            "kind": self.kind,
656            "class": self.class,
657            "target": self.target,
658            "exported": self.exported,
659            "exported_reason": self.exported_reason,
660            "enabled": self.enabled,
661            "permission": self.permission,
662            "permission_from_application": self.permission_from_app,
663            "process": self.process,
664            "authorities": self.authorities,
665            "grant_uri_permissions": self.grant_uri_permissions,
666            "read_permission": self.read_permission,
667            "write_permission": self.write_permission,
668            "intent_filters": self.intent_filters.iter().map(IntentFilter::json).collect::<Vec<_>>(),
669            "deep_links": self.deep_links(),
670            "meta_data": self.meta_data.iter().map(|(k, v)| json!({ "name": k, "value": v })).collect::<Vec<_>>(),
671            "provider_paths": self.provider_paths,
672        })
673    }
674
675    /// [`IntentFilter::deep_links`] of every filter, deduplicated.
676    pub fn deep_links(&self) -> Vec<String> {
677        let mut v: Vec<String> = self.intent_filters.iter().flat_map(IntentFilter::deep_links).collect();
678        v.dedup();
679        v
680    }
681}
682
683impl ManifestSummary {
684    /// One record per line, the first word naming the record type:
685    /// `package`, `application`, `uses-permission`, `permission`, a component
686    /// kind, `intent-filter`, `deeplink`, `provider-path`, `nsc`, `warning`.
687    /// Component lines carry the class so `grep exported=true` lists the
688    /// attack surface directly.
689    pub fn lines(&self) -> Vec<String> {
690        let opt = |o: &Option<String>| o.as_deref().map_or("-".to_string(), |v| value(v).into_owned());
691        let opt_i = |o: Option<i64>| o.map_or("-".to_string(), |v| v.to_string());
692        let mut out = vec![format!(
693            "package {} versionCode={} versionName={} minSdk={} targetSdk={} sharedUserId={}",
694            value(&self.package),
695            opt(&self.version_code),
696            opt(&self.version_name),
697            opt_i(self.min_sdk),
698            opt_i(self.target_sdk),
699            opt(&self.shared_user_id)
700        )];
701        let a = &self.application;
702        out.push(format!(
703            "application class={} debuggable={} allowBackup={} testOnly={} usesCleartextTraffic={} cleartext={}({}) networkSecurityConfig={} permission={} fullBackupContent={} dataExtractionRules={}",
704            opt(&a.class),
705            a.debuggable,
706            a.allow_backup,
707            a.test_only,
708            a.uses_cleartext_traffic.map_or("-".to_string(), |b| b.to_string()),
709            a.cleartext_effective.0,
710            a.cleartext_effective.1,
711            opt(&a.network_security_config),
712            opt(&a.permission),
713            opt(&a.full_backup_content),
714            opt(&a.data_extraction_rules),
715        ));
716        out.extend(self.uses_permissions.iter().map(|p| format!("uses-permission {}", value(p))));
717        out.extend(
718            self.permissions.iter().map(|p| format!("permission {} protectionLevel={}", value(&p.name), value(&p.protection_level))),
719        );
720        for c in &self.components {
721            out.push(c.line());
722            let class = value(&c.class);
723            for f in &c.intent_filters {
724                out.push(format!("intent-filter {class} {}", f.line()));
725            }
726            for l in c.deep_links() {
727                out.push(format!("deeplink {class} {}", value(&l)));
728            }
729            for p in &c.provider_paths {
730                out.push(format!("provider-path {} {p}", c.authorities()));
731            }
732        }
733        out.extend(self.network_security.iter().map(|l| format!("nsc {l}")));
734        out.extend(self.warnings.iter().map(|w| format!("warning {w}")));
735        out
736    }
737
738    /// The summary as a JSON object (snake_case keys).
739    pub fn json(&self) -> Json {
740        let a = &self.application;
741        json!({
742            "package": self.package,
743            "version_code": self.version_code,
744            "version_name": self.version_name,
745            "min_sdk": self.min_sdk,
746            "target_sdk": self.target_sdk,
747            "shared_user_id": self.shared_user_id,
748            "application": {
749                "class": a.class,
750                "debuggable": a.debuggable,
751                "allow_backup": a.allow_backup,
752                "test_only": a.test_only,
753                "uses_cleartext_traffic": a.uses_cleartext_traffic,
754                "cleartext": a.cleartext_effective.0,
755                "cleartext_source": a.cleartext_effective.1,
756                "network_security_config": a.network_security_config,
757                "permission": a.permission,
758                "full_backup_content": a.full_backup_content,
759                "data_extraction_rules": a.data_extraction_rules,
760            },
761            "uses_permissions": self.uses_permissions,
762            "permissions": self.permissions.iter().map(|p| json!({ "name": p.name, "protection_level": p.protection_level })).collect::<Vec<_>>(),
763            "components": self.components.iter().map(Component::json).collect::<Vec<_>>(),
764            "network_security": self.network_security,
765            "warnings": self.warnings,
766        })
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn class_names_and_levels() {
776        assert_eq!(class_name("com.a", ".Main"), "com.a.Main");
777        assert_eq!(class_name("com.a", "Main"), "com.a.Main");
778        assert_eq!(class_name("com.a", "org.b.Main"), "org.b.Main");
779        assert_eq!(protection_level_name(0), "normal");
780        assert_eq!(protection_level_name(0x12), "signature|privileged");
781        assert_eq!(protection_level_name(0x4000_0002), "signature|0x40000000");
782    }
783
784    #[test]
785    fn deep_link_patterns() {
786        let f = IntentFilter {
787            actions: vec!["android.intent.action.VIEW".into()],
788            categories: vec!["android.intent.category.BROWSABLE".into()],
789            data: vec![
790                DataSpec { scheme: Some("https".into()), ..Default::default() },
791                DataSpec { host: Some("a.com".into()), ..Default::default() },
792                DataSpec { path: Some(("pathPrefix".into(), "/x".into())), ..Default::default() },
793                DataSpec { scheme: Some("myapp".into()), ..Default::default() },
794            ],
795        };
796        assert_eq!(f.deep_links(), ["https://a.com/x*", "myapp://a.com/x*"]);
797        let none = IntentFilter { actions: vec!["android.intent.action.MAIN".into()], ..Default::default() };
798        assert!(none.deep_links().is_empty());
799    }
800}