Skip to main content

rustferry_android/
apk.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs::{self, File, OpenOptions},
4    io::Read,
5};
6
7use camino::{Utf8Path, Utf8PathBuf};
8use rustferry_core::{
9    AndroidAbi, AndroidLiveActivityFallback, ArtifactDigest, ArtifactDigestKind, FerryConfig,
10    digest_artifact,
11};
12use serde::{Deserialize, Serialize};
13use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
14
15use crate::{AndroidError, error::io_error, generate::android_manifest_permissions};
16
17/// Native library produced for one configured ABI.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct NativeLibraryInput {
20    /// Android ABI directory.
21    pub abi: AndroidAbi,
22    /// Compiled ELF shared object.
23    pub path: Utf8PathBuf,
24}
25
26/// Independent expectations for the final APK archive.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct ApkExpectation {
29    /// Application ID expected from AAPT2 badging output.
30    pub package_name: String,
31    /// Native library basename without `lib` and `.so`.
32    pub native_library_name: String,
33    /// ABIs that must be present exactly once.
34    pub abis: Vec<AndroidAbi>,
35    /// Whether at least `classes.dex` is mandatory.
36    pub dex_required: bool,
37}
38
39/// Evidence returned by independent APK inspection.
40#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41pub struct ApkValidation {
42    /// SHA-256 identity of the exact APK bytes inspected by the platform validator.
43    pub artifact_digest: ArtifactDigest,
44    /// Package ID parsed from `aapt2 dump badging`.
45    pub package_name: String,
46    /// Launcher activity parsed from AAPT2.
47    pub launcher_activity: String,
48    /// Archive entries, sorted for stable JSON output.
49    pub entries: Vec<String>,
50    /// Native ABIs whose ELF headers matched their APK directories.
51    pub native_abis: Vec<String>,
52    /// Number of sequential DEX files.
53    pub dex_files: usize,
54    /// Evidence proven from the compiled binary manifest.
55    pub manifest: Box<ManifestValidation>,
56}
57
58/// Stable evidence extracted from the final APK's compiled binary manifest.
59#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ManifestValidation {
61    /// Permissions and optional maximum SDK constraints, sorted by permission name.
62    pub permissions: Vec<String>,
63    /// Application components as `kind:class`, sorted by class name.
64    pub components: Vec<String>,
65    /// Deep-link data filters, sorted by scheme, host, and path prefix.
66    pub deep_link_filters: Vec<String>,
67}
68
69/// Append stored native libraries and DEX files to an AAPT2-linked APK.
70///
71/// # Errors
72///
73/// Returns an error for missing/unsafe inputs, duplicate entries, I/O failures, or malformed ZIPs.
74pub fn inject_apk_entries(
75    apk: &Utf8Path,
76    native_libraries: &[NativeLibraryInput],
77    dex_files: &[Utf8PathBuf],
78) -> Result<(), AndroidError> {
79    let existing = archive_entry_names(apk)?;
80    let mut additions = BTreeMap::<String, Utf8PathBuf>::new();
81    for native in native_libraries {
82        if !native.path.is_file() {
83            return Err(AndroidError::InvalidArtifact {
84                path: native.path.clone(),
85                reason: "native library file is missing".to_owned(),
86            });
87        }
88        let name = native
89            .path
90            .file_name()
91            .ok_or_else(|| AndroidError::InvalidArtifact {
92                path: native.path.clone(),
93                reason: "native library has no filename".to_owned(),
94            })?;
95        if !name.starts_with("lib") || Utf8Path::new(name).extension() != Some("so") {
96            return Err(AndroidError::InvalidArtifact {
97                path: native.path.clone(),
98                reason: "native library filename must be `lib<name>.so`".to_owned(),
99            });
100        }
101        additions.insert(
102            format!("lib/{}/{}", native.abi.apk_directory(), name),
103            native.path.clone(),
104        );
105    }
106    for dex in dex_files {
107        let name = dex
108            .file_name()
109            .ok_or_else(|| AndroidError::InvalidArtifact {
110                path: dex.clone(),
111                reason: "DEX file has no filename".to_owned(),
112            })?;
113        if !valid_dex_name(name) {
114            return Err(AndroidError::InvalidArtifact {
115                path: dex.clone(),
116                reason: format!("D8 output `{name}` is not classes.dex/classesN.dex"),
117            });
118        }
119        additions.insert(name.to_owned(), dex.clone());
120    }
121    if let Some(collision) = additions.keys().find(|name| existing.contains(*name)) {
122        return Err(AndroidError::InvalidArtifact {
123            path: apk.to_owned(),
124            reason: format!(
125                "AAPT2 output already contains `{collision}`; refusing duplicate ZIP entries"
126            ),
127        });
128    }
129
130    let file = OpenOptions::new()
131        .read(true)
132        .write(true)
133        .open(apk)
134        .map_err(|source| io_error("open APK for native/DEX injection", apk, source))?;
135    let mut writer = ZipWriter::new_append(file).map_err(|error| AndroidError::Zip {
136        path: apk.to_owned(),
137        message: error.to_string(),
138    })?;
139    let options = SimpleFileOptions::default()
140        .compression_method(CompressionMethod::Stored)
141        .unix_permissions(0o644);
142    for (entry, path) in additions {
143        writer
144            .start_file(&entry, options)
145            .map_err(|error| AndroidError::Zip {
146                path: apk.to_owned(),
147                message: format!("could not start `{entry}`: {error}"),
148            })?;
149        let mut input = File::open(&path)
150            .map_err(|source| io_error("open APK injection input", &path, source))?;
151        std::io::copy(&mut input, &mut writer)
152            .map_err(|source| io_error("append APK entry", apk, source))?;
153    }
154    writer.finish().map_err(|error| AndroidError::Zip {
155        path: apk.to_owned(),
156        message: error.to_string(),
157    })?;
158    Ok(())
159}
160
161/// Collect and validate sequential D8 output names from a directory.
162///
163/// # Errors
164///
165/// Returns an error when the directory is unreadable, `classes.dex` is absent, numbering has a
166/// gap, or a DEX header is invalid.
167pub fn collect_d8_outputs(directory: &Utf8Path) -> Result<Vec<Utf8PathBuf>, AndroidError> {
168    let entries = fs::read_dir(directory)
169        .map_err(|source| io_error("read D8 output directory", directory, source))?;
170    let mut dex = entries
171        .filter_map(Result::ok)
172        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
173        .filter(|path| path.file_name().is_some_and(valid_dex_name))
174        .collect::<Vec<_>>();
175    dex.sort_by_key(|path| dex_index(path.file_name().unwrap_or_default()));
176    if dex.is_empty() || dex_index(dex[0].file_name().unwrap_or_default()) != Some(1) {
177        return Err(AndroidError::InvalidArtifact {
178            path: directory.to_owned(),
179            reason: "D8 did not produce classes.dex".to_owned(),
180        });
181    }
182    for (offset, path) in dex.iter().enumerate() {
183        if dex_index(path.file_name().unwrap_or_default()) != Some(offset + 1) {
184            return Err(AndroidError::InvalidArtifact {
185                path: directory.to_owned(),
186                reason: "D8 output contains a gap in classes.dex/classesN.dex numbering".to_owned(),
187            });
188        }
189        validate_dex_header(path)?;
190    }
191    Ok(dex)
192}
193
194/// Validate ZIP safety, resources, DEX headers, and ELF ABI headers without trusting build tools.
195///
196/// # Errors
197///
198/// Returns an error for malformed/unsafe archives or any unmet package, DEX, resource, or ABI
199/// invariant.
200pub fn validate_apk_archive(
201    apk: &Utf8Path,
202    expectation: &ApkExpectation,
203) -> Result<ApkValidation, AndroidError> {
204    let file =
205        File::open(apk).map_err(|source| io_error("open APK for validation", apk, source))?;
206    let mut archive = ZipArchive::new(file).map_err(|error| AndroidError::Zip {
207        path: apk.to_owned(),
208        message: error.to_string(),
209    })?;
210    let mut entries = Vec::new();
211    let mut seen = BTreeSet::new();
212    let mut dex_names = Vec::new();
213    let mut native_abis = Vec::new();
214    let expected_native_entries = expectation
215        .abis
216        .iter()
217        .map(|abi| {
218            format!(
219                "lib/{}/lib{}.so",
220                abi.apk_directory(),
221                expectation.native_library_name
222            )
223        })
224        .collect::<BTreeSet<_>>();
225    let mut native_entries = BTreeSet::new();
226    for index in 0..archive.len() {
227        let mut entry = archive.by_index(index).map_err(|error| AndroidError::Zip {
228            path: apk.to_owned(),
229            message: error.to_string(),
230        })?;
231        let name = entry.name().to_owned();
232        validate_archive_name(apk, &name)?;
233        if !seen.insert(name.clone()) {
234            return Err(AndroidError::InvalidArtifact {
235                path: apk.to_owned(),
236                reason: format!("duplicate ZIP entry `{name}`"),
237            });
238        }
239        if valid_dex_name(&name) {
240            if entry.compression() != CompressionMethod::Stored {
241                return Err(AndroidError::InvalidArtifact {
242                    path: apk.to_owned(),
243                    reason: format!(
244                        "DEX entry `{name}` is compressed; injection must store it before alignment"
245                    ),
246                });
247            }
248            let mut header = [0_u8; 8];
249            entry
250                .read_exact(&mut header)
251                .map_err(|source| io_error("read DEX header", apk, source))?;
252            if !valid_dex_magic(header) {
253                return Err(AndroidError::InvalidArtifact {
254                    path: apk.to_owned(),
255                    reason: format!("DEX entry `{name}` has an invalid magic header"),
256                });
257            }
258            dex_names.push(name.clone());
259        }
260        if name.starts_with("lib/") && Utf8Path::new(&name).extension() == Some("so") {
261            if !expected_native_entries.contains(&name) {
262                return Err(AndroidError::InvalidArtifact {
263                    path: apk.to_owned(),
264                    reason: format!("unexpected native library entry `{name}`"),
265                });
266            }
267            for abi in &expectation.abis {
268                let expected = format!(
269                    "lib/{}/lib{}.so",
270                    abi.apk_directory(),
271                    expectation.native_library_name
272                );
273                if name != expected {
274                    continue;
275                }
276                if entry.compression() != CompressionMethod::Stored {
277                    return Err(AndroidError::InvalidArtifact {
278                        path: apk.to_owned(),
279                        reason: format!(
280                            "native library `{name}` is compressed and cannot be 16 KiB page-aligned"
281                        ),
282                    });
283                }
284                validate_elf_reader(apk, &mut entry, *abi)?;
285                native_abis.push(abi.apk_directory().to_owned());
286                native_entries.insert(name.clone());
287                break;
288            }
289        }
290        entries.push(name);
291    }
292
293    for required in ["AndroidManifest.xml", "resources.arsc"] {
294        if !seen.contains(required) {
295            return Err(AndroidError::InvalidArtifact {
296                path: apk.to_owned(),
297                reason: format!("required APK entry `{required}` is missing"),
298            });
299        }
300    }
301    for density in ["mdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi"] {
302        let prefix = format!("res/mipmap-{density}");
303        if !seen
304            .iter()
305            .any(|name| name.starts_with(&prefix) && name.contains("ferry_icon"))
306        {
307            return Err(AndroidError::InvalidArtifact {
308                path: apk.to_owned(),
309                reason: format!("compiled {density} launcher icon resource is missing"),
310            });
311        }
312    }
313    if !seen
314        .iter()
315        .any(|name| name.starts_with("res/drawable") && name.contains("ferry_splash"))
316    {
317        return Err(AndroidError::InvalidArtifact {
318            path: apk.to_owned(),
319            reason: "compiled splash resource is missing".to_owned(),
320        });
321    }
322    native_abis.sort();
323    native_abis.dedup();
324    if native_entries != expected_native_entries {
325        return Err(AndroidError::InvalidArtifact {
326            path: apk.to_owned(),
327            reason: format!(
328                "native library set mismatch: expected {expected_native_entries:?}, found {native_entries:?}"
329            ),
330        });
331    }
332    dex_names.sort_by_key(|name| dex_index(name));
333    if expectation.dex_required && dex_names.first().map(String::as_str) != Some("classes.dex") {
334        return Err(AndroidError::InvalidArtifact {
335            path: apk.to_owned(),
336            reason: "classes.dex is required but missing".to_owned(),
337        });
338    }
339    for (offset, name) in dex_names.iter().enumerate() {
340        if dex_index(name) != Some(offset + 1) {
341            return Err(AndroidError::InvalidArtifact {
342                path: apk.to_owned(),
343                reason: "DEX entries are not sequentially numbered".to_owned(),
344            });
345        }
346    }
347    entries.sort();
348    Ok(ApkValidation {
349        artifact_digest: apk_artifact_digest(apk)?,
350        package_name: String::new(),
351        launcher_activity: String::new(),
352        entries,
353        native_abis,
354        dex_files: dex_names.len(),
355        manifest: Box::default(),
356    })
357}
358
359pub(crate) fn apk_artifact_digest(apk: &Utf8Path) -> Result<ArtifactDigest, AndroidError> {
360    digest_artifact(apk, ArtifactDigestKind::AndroidApk).map_err(|error| {
361        AndroidError::InvalidArtifact {
362            path: apk.to_owned(),
363            reason: format!("could not bind validation to exact APK bytes: {error}"),
364        }
365    })
366}
367
368/// Parse and verify `aapt2 dump badging` package and launcher metadata.
369///
370/// # Errors
371///
372/// Returns an error when package or launcher metadata is missing or differs from expectations.
373pub fn validate_aapt2_badging(
374    apk: &Utf8Path,
375    output: &[u8],
376    expectation: &ApkExpectation,
377    validation: &mut ApkValidation,
378) -> Result<(), AndroidError> {
379    let output = String::from_utf8_lossy(output);
380    let package = extract_quoted_field(&output, "package:", "name=").ok_or_else(|| {
381        AndroidError::InvalidArtifact {
382            path: apk.to_owned(),
383            reason: "aapt2 badging output has no package name".to_owned(),
384        }
385    })?;
386    if package != expectation.package_name {
387        return Err(AndroidError::InvalidArtifact {
388            path: apk.to_owned(),
389            reason: format!(
390                "package ID mismatch: expected `{}`, found `{package}`",
391                expectation.package_name
392            ),
393        });
394    }
395    let launcher =
396        extract_quoted_field(&output, "launchable-activity:", "name=").ok_or_else(|| {
397            AndroidError::InvalidArtifact {
398                path: apk.to_owned(),
399                reason: "aapt2 badging output has no launcher activity".to_owned(),
400            }
401        })?;
402    if launcher != crate::ACTIVITY_CLASS {
403        return Err(AndroidError::InvalidArtifact {
404            path: apk.to_owned(),
405            reason: format!(
406                "unexpected launcher activity `{launcher}`; expected `{}`",
407                crate::ACTIVITY_CLASS
408            ),
409        });
410    }
411    validation.package_name = package;
412    validation.launcher_activity = launcher;
413    Ok(())
414}
415
416/// Verify SDK levels, permissions, components, and deep-link filters from AAPT2 XML output.
417///
418/// # Errors
419///
420/// Returns an error when the compiled binary manifest differs from the resolved configuration.
421pub fn validate_aapt2_manifest(
422    apk: &Utf8Path,
423    output: &[u8],
424    expectation: &ApkExpectation,
425    config: &FerryConfig,
426    target_sdk: u32,
427    validation: &mut ApkValidation,
428) -> Result<(), AndroidError> {
429    let nodes = parse_xmltree(apk, output)?;
430    let manifest = single_node(apk, &nodes, None, "manifest")?;
431    require_attribute(apk, &nodes[manifest], "package", &expectation.package_name)?;
432    let uses_sdk = single_node(apk, &nodes, Some(manifest), "uses-sdk")?;
433    require_attribute(
434        apk,
435        &nodes[uses_sdk],
436        "minSdkVersion",
437        &config.android.min_sdk.to_string(),
438    )?;
439    require_attribute(
440        apk,
441        &nodes[uses_sdk],
442        "targetSdkVersion",
443        &target_sdk.to_string(),
444    )?;
445    let application = single_node(apk, &nodes, Some(manifest), "application")?;
446    require_attribute(apk, &nodes[application], "hasCode", "true")?;
447
448    let expected_permissions = android_manifest_permissions(config, target_sdk)
449        .into_iter()
450        .map(|(name, max_sdk)| (name.to_owned(), max_sdk))
451        .collect::<BTreeMap<_, _>>();
452    let mut actual_permissions = BTreeMap::new();
453    for index in child_nodes(&nodes, manifest, "uses-permission") {
454        let name = required_attribute(apk, &nodes[index], "name")?.to_owned();
455        let max_sdk = nodes[index]
456            .attributes
457            .get("maxSdkVersion")
458            .map(|value| {
459                value
460                    .parse::<u32>()
461                    .map_err(|_| AndroidError::InvalidArtifact {
462                        path: apk.to_owned(),
463                        reason: format!("compiled permission `{name}` has invalid maxSdkVersion"),
464                    })
465            })
466            .transpose()?;
467        if actual_permissions.insert(name.clone(), max_sdk).is_some() {
468            return Err(invalid_manifest(
469                apk,
470                format!("duplicate compiled permission `{name}`"),
471            ));
472        }
473    }
474    if actual_permissions != expected_permissions {
475        return Err(invalid_manifest(
476            apk,
477            format!(
478                "compiled permissions differ from configuration: expected {expected_permissions:?}, found {actual_permissions:?}"
479            ),
480        ));
481    }
482
483    let notification_component = config.capabilities.notifications.local
484        || (config.extensions.live_activity.enabled
485            && config.extensions.live_activity.android_fallback
486                == AndroidLiveActivityFallback::OngoingNotification);
487    let mut expected_components =
488        BTreeMap::from([(crate::ACTIVITY_CLASS.to_owned(), "activity".to_owned())]);
489    if notification_component {
490        expected_components.insert(
491            crate::NOTIFICATION_RECEIVER_CLASS.to_owned(),
492            "receiver".to_owned(),
493        );
494    }
495    if config.extensions.widget.enabled {
496        expected_components.insert(
497            crate::WIDGET_PROVIDER_CLASS.to_owned(),
498            "receiver".to_owned(),
499        );
500    }
501    if config.capabilities.share.enabled {
502        expected_components.insert(crate::FILE_PROVIDER_CLASS.to_owned(), "provider".to_owned());
503    }
504    let component_kinds = [
505        "activity",
506        "activity-alias",
507        "provider",
508        "receiver",
509        "service",
510    ];
511    let mut actual_components = BTreeMap::new();
512    let mut component_indices = BTreeMap::new();
513    for kind in component_kinds {
514        for index in child_nodes(&nodes, application, kind) {
515            let name = required_attribute(apk, &nodes[index], "name")?.to_owned();
516            if actual_components
517                .insert(name.clone(), kind.to_owned())
518                .is_some()
519            {
520                return Err(invalid_manifest(
521                    apk,
522                    format!("duplicate component `{name}`"),
523                ));
524            }
525            component_indices.insert(name, index);
526        }
527    }
528    if actual_components != expected_components {
529        return Err(invalid_manifest(
530            apk,
531            format!(
532                "compiled components differ from configuration: expected {expected_components:?}, found {actual_components:?}"
533            ),
534        ));
535    }
536
537    let activity = component_indices[crate::ACTIVITY_CLASS];
538    require_attribute(apk, &nodes[activity], "exported", "true")?;
539    let launch_mode = required_attribute(apk, &nodes[activity], "launchMode")?;
540    if launch_mode != "1" && launch_mode != "singleTop" {
541        return Err(invalid_manifest(
542            apk,
543            format!("compiled activity launchMode is not singleTop: `{launch_mode}`"),
544        ));
545    }
546    require_metadata(
547        apk,
548        &nodes,
549        activity,
550        "android.app.lib_name",
551        Some(("value", &expectation.native_library_name)),
552    )?;
553    if notification_component {
554        let receiver = component_indices[crate::NOTIFICATION_RECEIVER_CLASS];
555        require_attribute(apk, &nodes[receiver], "enabled", "true")?;
556        require_attribute(apk, &nodes[receiver], "exported", "false")?;
557    }
558    if config.extensions.widget.enabled {
559        let receiver = component_indices[crate::WIDGET_PROVIDER_CLASS];
560        require_attribute(apk, &nodes[receiver], "enabled", "true")?;
561        require_attribute(apk, &nodes[receiver], "exported", "false")?;
562        require_metadata(apk, &nodes, receiver, "android.appwidget.provider", None)?;
563        require_intent_filter(
564            apk,
565            &nodes,
566            receiver,
567            &["android.appwidget.action.APPWIDGET_UPDATE"],
568            &[],
569        )?;
570    }
571    if config.capabilities.share.enabled {
572        let provider = component_indices[crate::FILE_PROVIDER_CLASS];
573        require_attribute(apk, &nodes[provider], "exported", "false")?;
574        require_attribute(apk, &nodes[provider], "grantUriPermissions", "true")?;
575        require_attribute(
576            apk,
577            &nodes[provider],
578            "authorities",
579            &format!("{}.ferry-files", expectation.package_name),
580        )?;
581    }
582
583    require_intent_filter(
584        apk,
585        &nodes,
586        activity,
587        &["android.intent.action.MAIN"],
588        &["android.intent.category.LAUNCHER"],
589    )?;
590    let expected_links = expected_deep_link_filters(config);
591    let mut actual_links = BTreeSet::new();
592    let mut view_filter_count = 0_usize;
593    for filter in child_nodes(&nodes, activity, "intent-filter") {
594        let actions = named_children(apk, &nodes, filter, "action")?;
595        if !actions.contains("android.intent.action.VIEW") {
596            continue;
597        }
598        view_filter_count += 1;
599        let categories = named_children(apk, &nodes, filter, "category")?;
600        let required_categories = BTreeSet::from([
601            "android.intent.category.BROWSABLE".to_owned(),
602            "android.intent.category.DEFAULT".to_owned(),
603        ]);
604        if actions != BTreeSet::from(["android.intent.action.VIEW".to_owned()])
605            || categories != required_categories
606        {
607            return Err(invalid_manifest(
608                apk,
609                "compiled deep-link filter has unexpected actions or categories",
610            ));
611        }
612        for data in child_nodes(&nodes, filter, "data") {
613            let scheme = required_attribute(apk, &nodes[data], "scheme")?.to_owned();
614            let host = nodes[data].attributes.get("host").cloned();
615            let path = nodes[data].attributes.get("pathPrefix").cloned();
616            actual_links.insert((scheme, host, path));
617        }
618    }
619    let expected_view_filters = usize::from(!expected_links.is_empty());
620    if view_filter_count != expected_view_filters || actual_links != expected_links {
621        return Err(invalid_manifest(
622            apk,
623            format!(
624                "compiled deep-link filters differ from configuration: expected {expected_links:?}, found {actual_links:?}"
625            ),
626        ));
627    }
628
629    validation.manifest.permissions = actual_permissions
630        .into_iter()
631        .map(|(name, max_sdk)| match max_sdk {
632            Some(max_sdk) => format!("{name} (maxSdkVersion={max_sdk})"),
633            None => name,
634        })
635        .collect();
636    validation.manifest.components = actual_components
637        .into_iter()
638        .map(|(name, kind)| format!("{kind}:{name}"))
639        .collect();
640    validation.manifest.deep_link_filters = actual_links
641        .into_iter()
642        .map(|(scheme, host, path)| {
643            format!(
644                "scheme={scheme};host={};pathPrefix={}",
645                host.as_deref().unwrap_or("*"),
646                path.as_deref().unwrap_or("*")
647            )
648        })
649        .collect();
650    Ok(())
651}
652
653#[derive(Debug)]
654struct XmlTreeNode {
655    name: String,
656    parent: Option<usize>,
657    attributes: BTreeMap<String, String>,
658}
659
660fn parse_xmltree(apk: &Utf8Path, output: &[u8]) -> Result<Vec<XmlTreeNode>, AndroidError> {
661    let text = std::str::from_utf8(output)
662        .map_err(|_| invalid_manifest(apk, "aapt2 xmltree output is not valid UTF-8"))?;
663    let mut nodes = Vec::<XmlTreeNode>::new();
664    let mut stack = Vec::<(usize, usize)>::new();
665    for line in text.lines() {
666        let trimmed = line.trim_start_matches(' ');
667        let indent = line.len() - trimmed.len();
668        if let Some(element) = trimmed.strip_prefix("E: ") {
669            let name = element.split_whitespace().next().unwrap_or_default();
670            if name.is_empty() {
671                return Err(invalid_manifest(
672                    apk,
673                    "aapt2 xmltree contains an unnamed element",
674                ));
675            }
676            while stack.last().is_some_and(|(level, _)| *level >= indent) {
677                stack.pop();
678            }
679            let parent = stack.last().map(|(_, index)| *index);
680            let index = nodes.len();
681            nodes.push(XmlTreeNode {
682                name: name.to_owned(),
683                parent,
684                attributes: BTreeMap::new(),
685            });
686            stack.push((indent, index));
687        } else if let Some(attribute) = trimmed.strip_prefix("A: ") {
688            let Some((_, index)) = stack.last() else {
689                return Err(invalid_manifest(
690                    apk,
691                    "aapt2 xmltree attribute has no element",
692                ));
693            };
694            let (name, value) = parse_xmltree_attribute(apk, attribute)?;
695            if nodes[*index]
696                .attributes
697                .insert(name.clone(), value)
698                .is_some()
699            {
700                return Err(invalid_manifest(
701                    apk,
702                    format!("aapt2 xmltree element has duplicate `{name}` attribute"),
703                ));
704            }
705        }
706    }
707    if nodes.is_empty() {
708        return Err(invalid_manifest(
709            apk,
710            "aapt2 xmltree output has no elements",
711        ));
712    }
713    Ok(nodes)
714}
715
716fn parse_xmltree_attribute(
717    apk: &Utf8Path,
718    attribute: &str,
719) -> Result<(String, String), AndroidError> {
720    let (key, encoded) = attribute
721        .split_once('=')
722        .ok_or_else(|| invalid_manifest(apk, "malformed aapt2 xmltree attribute"))?;
723    let name = key
724        .rsplit(':')
725        .next()
726        .unwrap_or(key)
727        .split('(')
728        .next()
729        .unwrap_or_default()
730        .trim();
731    if name.is_empty() {
732        return Err(invalid_manifest(apk, "aapt2 xmltree attribute has no name"));
733    }
734    let value = if let Some((_, raw)) = encoded.rsplit_once("(Raw: \"") {
735        raw.strip_suffix("\")")
736            .ok_or_else(|| invalid_manifest(apk, "malformed raw aapt2 attribute value"))?
737            .to_owned()
738    } else {
739        let encoded = encoded.trim();
740        if let Some(quoted) = encoded.strip_prefix('"') {
741            quoted
742                .split('"')
743                .next()
744                .ok_or_else(|| invalid_manifest(apk, "malformed quoted aapt2 attribute value"))?
745                .to_owned()
746        } else {
747            encoded
748                .split_whitespace()
749                .next()
750                .unwrap_or_default()
751                .to_owned()
752        }
753    };
754    Ok((name.to_owned(), value))
755}
756
757fn child_nodes(nodes: &[XmlTreeNode], parent: usize, name: &str) -> Vec<usize> {
758    nodes
759        .iter()
760        .enumerate()
761        .filter_map(|(index, node)| {
762            (node.parent == Some(parent) && node.name == name).then_some(index)
763        })
764        .collect()
765}
766
767fn single_node(
768    apk: &Utf8Path,
769    nodes: &[XmlTreeNode],
770    parent: Option<usize>,
771    name: &str,
772) -> Result<usize, AndroidError> {
773    let matches = nodes
774        .iter()
775        .enumerate()
776        .filter_map(|(index, node)| (node.parent == parent && node.name == name).then_some(index))
777        .collect::<Vec<_>>();
778    if matches.len() == 1 {
779        Ok(matches[0])
780    } else {
781        Err(invalid_manifest(
782            apk,
783            format!(
784                "compiled manifest expected one `{name}` element, found {}",
785                matches.len()
786            ),
787        ))
788    }
789}
790
791fn required_attribute<'a>(
792    apk: &Utf8Path,
793    node: &'a XmlTreeNode,
794    name: &str,
795) -> Result<&'a str, AndroidError> {
796    node.attributes
797        .get(name)
798        .map(String::as_str)
799        .ok_or_else(|| {
800            invalid_manifest(
801                apk,
802                format!("compiled `{}` element is missing `{name}`", node.name),
803            )
804        })
805}
806
807fn require_attribute(
808    apk: &Utf8Path,
809    node: &XmlTreeNode,
810    name: &str,
811    expected: &str,
812) -> Result<(), AndroidError> {
813    let actual = required_attribute(apk, node, name)?;
814    if actual == expected {
815        Ok(())
816    } else {
817        Err(invalid_manifest(
818            apk,
819            format!(
820                "compiled `{}` attribute `{name}` expected `{expected}`, found `{actual}`",
821                node.name
822            ),
823        ))
824    }
825}
826
827fn named_children(
828    apk: &Utf8Path,
829    nodes: &[XmlTreeNode],
830    parent: usize,
831    kind: &str,
832) -> Result<BTreeSet<String>, AndroidError> {
833    child_nodes(nodes, parent, kind)
834        .into_iter()
835        .map(|index| required_attribute(apk, &nodes[index], "name").map(ToOwned::to_owned))
836        .collect()
837}
838
839fn require_metadata(
840    apk: &Utf8Path,
841    nodes: &[XmlTreeNode],
842    parent: usize,
843    name: &str,
844    value: Option<(&str, &str)>,
845) -> Result<(), AndroidError> {
846    let matches = child_nodes(nodes, parent, "meta-data")
847        .into_iter()
848        .filter(|index| {
849            nodes[*index]
850                .attributes
851                .get("name")
852                .is_some_and(|actual| actual == name)
853        })
854        .collect::<Vec<_>>();
855    if matches.len() != 1 {
856        return Err(invalid_manifest(
857            apk,
858            format!("compiled component expected one `{name}` metadata entry"),
859        ));
860    }
861    if let Some((attribute, expected)) = value {
862        require_attribute(apk, &nodes[matches[0]], attribute, expected)?;
863    } else if !nodes[matches[0]].attributes.contains_key("resource") {
864        return Err(invalid_manifest(
865            apk,
866            format!("compiled `{name}` metadata has no resource"),
867        ));
868    }
869    Ok(())
870}
871
872fn require_intent_filter(
873    apk: &Utf8Path,
874    nodes: &[XmlTreeNode],
875    parent: usize,
876    actions: &[&str],
877    categories: &[&str],
878) -> Result<(), AndroidError> {
879    let expected_actions = actions.iter().map(|value| (*value).to_owned()).collect();
880    let expected_categories = categories.iter().map(|value| (*value).to_owned()).collect();
881    let mut found = 0;
882    for filter in child_nodes(nodes, parent, "intent-filter") {
883        if named_children(apk, nodes, filter, "action")? == expected_actions
884            && named_children(apk, nodes, filter, "category")? == expected_categories
885        {
886            found += 1;
887        }
888    }
889    if found == 1 {
890        Ok(())
891    } else {
892        Err(invalid_manifest(
893            apk,
894            format!("compiled component expected one intent filter for actions {actions:?}"),
895        ))
896    }
897}
898
899fn expected_deep_link_filters(
900    config: &FerryConfig,
901) -> BTreeSet<(String, Option<String>, Option<String>)> {
902    let hosts = if config.capabilities.deep_links.allowed_hosts.is_empty() {
903        vec![None]
904    } else {
905        config
906            .capabilities
907            .deep_links
908            .allowed_hosts
909            .iter()
910            .cloned()
911            .map(Some)
912            .collect()
913    };
914    let paths = if config.capabilities.deep_links.allowed_actions.is_empty() {
915        vec![None]
916    } else {
917        config
918            .capabilities
919            .deep_links
920            .allowed_actions
921            .iter()
922            .map(|action| Some(format!("/{action}")))
923            .collect()
924    };
925    let mut filters = BTreeSet::new();
926    for scheme in &config.capabilities.deep_links.schemes {
927        for host in &hosts {
928            for path in &paths {
929                filters.insert((scheme.clone(), host.clone(), path.clone()));
930            }
931        }
932    }
933    filters
934}
935
936fn invalid_manifest(apk: &Utf8Path, reason: impl Into<String>) -> AndroidError {
937    AndroidError::InvalidArtifact {
938        path: apk.to_owned(),
939        reason: reason.into(),
940    }
941}
942
943fn archive_entry_names(apk: &Utf8Path) -> Result<BTreeSet<String>, AndroidError> {
944    let file = File::open(apk).map_err(|source| io_error("open AAPT2 APK", apk, source))?;
945    let mut archive = ZipArchive::new(file).map_err(|error| AndroidError::Zip {
946        path: apk.to_owned(),
947        message: error.to_string(),
948    })?;
949    let mut names = BTreeSet::new();
950    for index in 0..archive.len() {
951        let entry = archive.by_index(index).map_err(|error| AndroidError::Zip {
952            path: apk.to_owned(),
953            message: error.to_string(),
954        })?;
955        validate_archive_name(apk, entry.name())?;
956        if !names.insert(entry.name().to_owned()) {
957            return Err(AndroidError::InvalidArtifact {
958                path: apk.to_owned(),
959                reason: format!("AAPT2 produced duplicate ZIP entry `{}`", entry.name()),
960            });
961        }
962    }
963    Ok(names)
964}
965
966fn validate_archive_name(apk: &Utf8Path, name: &str) -> Result<(), AndroidError> {
967    if name.is_empty()
968        || name.starts_with('/')
969        || name.contains('\\')
970        || name
971            .split('/')
972            .any(|segment| segment == ".." || segment == ".")
973    {
974        return Err(AndroidError::InvalidArtifact {
975            path: apk.to_owned(),
976            reason: format!("unsafe ZIP entry name `{name}`"),
977        });
978    }
979    Ok(())
980}
981
982fn validate_dex_header(path: &Utf8Path) -> Result<(), AndroidError> {
983    let mut file = File::open(path).map_err(|source| io_error("open D8 output", path, source))?;
984    let mut header = [0_u8; 8];
985    file.read_exact(&mut header)
986        .map_err(|source| io_error("read D8 output header", path, source))?;
987    if valid_dex_magic(header) {
988        Ok(())
989    } else {
990        Err(AndroidError::InvalidArtifact {
991            path: path.to_owned(),
992            reason: "invalid DEX magic header".to_owned(),
993        })
994    }
995}
996
997fn valid_dex_magic(header: [u8; 8]) -> bool {
998    &header[..4] == b"dex\n" && header[4..7].iter().all(u8::is_ascii_digit) && header[7] == 0
999}
1000
1001fn valid_dex_name(name: &str) -> bool {
1002    dex_index(name).is_some()
1003}
1004
1005fn dex_index(name: &str) -> Option<usize> {
1006    if name == "classes.dex" {
1007        return Some(1);
1008    }
1009    let index_text = name.strip_prefix("classes")?.strip_suffix(".dex")?;
1010    let index = index_text.parse::<usize>().ok()?;
1011    (index >= 2 && index.to_string() == index_text).then_some(index)
1012}
1013
1014fn validate_elf_reader(
1015    apk: &Utf8Path,
1016    reader: &mut impl Read,
1017    abi: AndroidAbi,
1018) -> Result<(), AndroidError> {
1019    let mut header = [0_u8; 20];
1020    reader
1021        .read_exact(&mut header)
1022        .map_err(|source| io_error("read native ELF header", apk, source))?;
1023    let expected_class = if abi == AndroidAbi::ArmeabiV7a { 1 } else { 2 };
1024    let expected_machine = match abi {
1025        AndroidAbi::Arm64V8a => 183,
1026        AndroidAbi::X86_64 => 62,
1027        AndroidAbi::ArmeabiV7a => 40,
1028    };
1029    let file_type = u16::from_le_bytes([header[16], header[17]]);
1030    let machine = u16::from_le_bytes([header[18], header[19]]);
1031    if &header[..4] != b"\x7fELF"
1032        || header[4] != expected_class
1033        || header[5] != 1
1034        || file_type != 3
1035        || machine != expected_machine
1036    {
1037        return Err(AndroidError::InvalidArtifact {
1038            path: apk.to_owned(),
1039            reason: format!(
1040                "native library ELF header does not match ABI {}",
1041                abi.apk_directory()
1042            ),
1043        });
1044    }
1045    Ok(())
1046}
1047
1048fn extract_quoted_field(output: &str, line_prefix: &str, field: &str) -> Option<String> {
1049    let line = output.lines().find(|line| line.starts_with(line_prefix))?;
1050    let tail = line.split_once(field)?.1;
1051    let tail = tail.strip_prefix('\'')?;
1052    Some(tail.split_once('\'')?.0.to_owned())
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use std::io::Write;
1058
1059    use tempfile::TempDir;
1060
1061    use super::*;
1062
1063    fn elf(abi: AndroidAbi) -> Vec<u8> {
1064        let mut header = vec![0_u8; 20];
1065        header[..4].copy_from_slice(b"\x7fELF");
1066        header[4] = if abi == AndroidAbi::ArmeabiV7a { 1 } else { 2 };
1067        header[5] = 1;
1068        header[16..18].copy_from_slice(&3_u16.to_le_bytes());
1069        let machine = match abi {
1070            AndroidAbi::Arm64V8a => 183_u16,
1071            AndroidAbi::X86_64 => 62,
1072            AndroidAbi::ArmeabiV7a => 40,
1073        };
1074        header[18..20].copy_from_slice(&machine.to_le_bytes());
1075        header
1076    }
1077
1078    fn base_apk(path: &Utf8Path) {
1079        let file = File::create(path).unwrap();
1080        let mut zip = ZipWriter::new(file);
1081        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1082        for (name, contents) in [
1083            ("AndroidManifest.xml", b"manifest".as_slice()),
1084            ("resources.arsc", b"resources".as_slice()),
1085            ("res/mipmap-mdpi-v4/ferry_icon.png", b"icon".as_slice()),
1086            ("res/mipmap-hdpi-v4/ferry_icon.png", b"icon".as_slice()),
1087            ("res/mipmap-xhdpi-v4/ferry_icon.png", b"icon".as_slice()),
1088            ("res/mipmap-xxhdpi-v4/ferry_icon.png", b"icon".as_slice()),
1089            ("res/mipmap-xxxhdpi-v4/ferry_icon.png", b"icon".as_slice()),
1090            ("res/drawable/ferry_splash.xml", b"splash".as_slice()),
1091        ] {
1092            zip.start_file(name, options).unwrap();
1093            zip.write_all(contents).unwrap();
1094        }
1095        zip.finish().unwrap();
1096    }
1097
1098    #[test]
1099    fn injection_and_independent_validation_cover_dex_and_elf() {
1100        let temp = TempDir::new().unwrap();
1101        let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1102        let apk = root.join("app.apk");
1103        let native = root.join("libweather.so");
1104        let dex = root.join("classes.dex");
1105        base_apk(&apk);
1106        fs::write(&native, elf(AndroidAbi::Arm64V8a)).unwrap();
1107        fs::write(&dex, b"dex\n035\0payload").unwrap();
1108        inject_apk_entries(
1109            &apk,
1110            &[NativeLibraryInput {
1111                abi: AndroidAbi::Arm64V8a,
1112                path: native,
1113            }],
1114            &[dex],
1115        )
1116        .unwrap();
1117        let validation = validate_apk_archive(
1118            &apk,
1119            &ApkExpectation {
1120                package_name: "com.example.weather".to_owned(),
1121                native_library_name: "weather".to_owned(),
1122                abis: vec![AndroidAbi::Arm64V8a],
1123                dex_required: true,
1124            },
1125        )
1126        .unwrap();
1127        assert_eq!(validation.native_abis, ["arm64-v8a"]);
1128        assert_eq!(validation.dex_files, 1);
1129    }
1130
1131    #[test]
1132    fn badging_requires_exact_package_and_native_activity() {
1133        let apk = Utf8Path::new("app.apk");
1134        let expectation = ApkExpectation {
1135            package_name: "com.example.weather".to_owned(),
1136            native_library_name: "weather".to_owned(),
1137            abis: vec![],
1138            dex_required: false,
1139        };
1140        let mut validation = ApkValidation {
1141            artifact_digest: ArtifactDigest {
1142                sha256: String::new(),
1143                entries: 0,
1144                bytes: 0,
1145            },
1146            package_name: String::new(),
1147            launcher_activity: String::new(),
1148            entries: vec![],
1149            native_abis: vec![],
1150            dex_files: 0,
1151            manifest: Box::default(),
1152        };
1153        validate_aapt2_badging(
1154            apk,
1155            b"package: name='com.example.weather' versionCode='1'\nlaunchable-activity: name='org.rustferry.bridge.FerryActivity' label='' icon=''\n",
1156            &expectation,
1157            &mut validation,
1158        )
1159        .unwrap();
1160        assert_eq!(validation.package_name, "com.example.weather");
1161        assert_eq!(validation.launcher_activity, crate::ACTIVITY_CLASS);
1162    }
1163
1164    #[test]
1165    fn compiled_manifest_matches_capabilities_and_records_stable_evidence() {
1166        let apk = Utf8Path::new("app.apk");
1167        let mut config = FerryConfig::starter("Weather", "com.example.weather");
1168        config.capabilities.share.enabled = true;
1169        config.capabilities.deep_links.schemes = vec!["weather".to_owned()];
1170        config.capabilities.deep_links.allowed_hosts = vec!["forecast.example".to_owned()];
1171        config.capabilities.deep_links.allowed_actions = vec!["today".to_owned()];
1172        config.extensions.widget.enabled = true;
1173        config.extensions.widget.app_group = Some("group.com.example.weather".to_owned());
1174        let expectation = ApkExpectation {
1175            package_name: "com.example.weather".to_owned(),
1176            native_library_name: "weather".to_owned(),
1177            abis: vec![],
1178            dex_required: true,
1179        };
1180        let xmltree = br#"
1181  E: manifest
1182    A: package="com.example.weather" (Raw: "com.example.weather")
1183      E: uses-permission
1184        A: http://schemas.android.com/apk/res/android:name="android.permission.ACCESS_NETWORK_STATE" (Raw: "android.permission.ACCESS_NETWORK_STATE")
1185      E: uses-permission
1186        A: http://schemas.android.com/apk/res/android:name="android.permission.POST_NOTIFICATIONS" (Raw: "android.permission.POST_NOTIFICATIONS")
1187      E: uses-permission
1188        A: http://schemas.android.com/apk/res/android:name="android.permission.VIBRATE" (Raw: "android.permission.VIBRATE")
1189      E: uses-sdk
1190        A: http://schemas.android.com/apk/res/android:minSdkVersion=26
1191        A: http://schemas.android.com/apk/res/android:targetSdkVersion=35
1192      E: application
1193        A: http://schemas.android.com/apk/res/android:hasCode=true
1194          E: activity
1195            A: http://schemas.android.com/apk/res/android:name="org.rustferry.bridge.FerryActivity" (Raw: "org.rustferry.bridge.FerryActivity")
1196            A: http://schemas.android.com/apk/res/android:exported=true
1197            A: http://schemas.android.com/apk/res/android:launchMode=2 (Raw: "singleTop")
1198              E: meta-data
1199                A: http://schemas.android.com/apk/res/android:name="android.app.lib_name" (Raw: "android.app.lib_name")
1200                A: http://schemas.android.com/apk/res/android:value="weather" (Raw: "weather")
1201              E: intent-filter
1202                  E: action
1203                    A: http://schemas.android.com/apk/res/android:name="android.intent.action.MAIN" (Raw: "android.intent.action.MAIN")
1204                  E: category
1205                    A: http://schemas.android.com/apk/res/android:name="android.intent.category.LAUNCHER" (Raw: "android.intent.category.LAUNCHER")
1206              E: intent-filter
1207                  E: action
1208                    A: http://schemas.android.com/apk/res/android:name="android.intent.action.VIEW" (Raw: "android.intent.action.VIEW")
1209                  E: category
1210                    A: http://schemas.android.com/apk/res/android:name="android.intent.category.DEFAULT" (Raw: "android.intent.category.DEFAULT")
1211                  E: category
1212                    A: http://schemas.android.com/apk/res/android:name="android.intent.category.BROWSABLE" (Raw: "android.intent.category.BROWSABLE")
1213                  E: data
1214                    A: http://schemas.android.com/apk/res/android:scheme="weather" (Raw: "weather")
1215                    A: http://schemas.android.com/apk/res/android:host="forecast.example" (Raw: "forecast.example")
1216                    A: http://schemas.android.com/apk/res/android:pathPrefix="/today" (Raw: "/today")
1217          E: receiver
1218            A: http://schemas.android.com/apk/res/android:name="org.rustferry.bridge.FerryNotificationReceiver" (Raw: "org.rustferry.bridge.FerryNotificationReceiver")
1219            A: http://schemas.android.com/apk/res/android:enabled=true
1220            A: http://schemas.android.com/apk/res/android:exported=false
1221          E: receiver
1222            A: http://schemas.android.com/apk/res/android:name="org.rustferry.bridge.FerryWidgetProvider" (Raw: "org.rustferry.bridge.FerryWidgetProvider")
1223            A: http://schemas.android.com/apk/res/android:enabled=true
1224            A: http://schemas.android.com/apk/res/android:exported=false
1225              E: intent-filter
1226                  E: action
1227                    A: http://schemas.android.com/apk/res/android:name="android.appwidget.action.APPWIDGET_UPDATE" (Raw: "android.appwidget.action.APPWIDGET_UPDATE")
1228              E: meta-data
1229                A: http://schemas.android.com/apk/res/android:name="android.appwidget.provider" (Raw: "android.appwidget.provider")
1230                A: http://schemas.android.com/apk/res/android:resource=@0x7f040000
1231          E: provider
1232            A: http://schemas.android.com/apk/res/android:name="org.rustferry.bridge.FerryFileProvider" (Raw: "org.rustferry.bridge.FerryFileProvider")
1233            A: http://schemas.android.com/apk/res/android:authorities="com.example.weather.ferry-files" (Raw: "com.example.weather.ferry-files")
1234            A: http://schemas.android.com/apk/res/android:exported=false
1235            A: http://schemas.android.com/apk/res/android:grantUriPermissions=true
1236"#;
1237        let mut validation = ApkValidation {
1238            artifact_digest: ArtifactDigest {
1239                sha256: String::new(),
1240                entries: 0,
1241                bytes: 0,
1242            },
1243            package_name: String::new(),
1244            launcher_activity: String::new(),
1245            entries: vec![],
1246            native_abis: vec![],
1247            dex_files: 0,
1248            manifest: Box::default(),
1249        };
1250        validate_aapt2_manifest(apk, xmltree, &expectation, &config, 35, &mut validation).unwrap();
1251        assert_eq!(
1252            validation.manifest.permissions,
1253            [
1254                "android.permission.ACCESS_NETWORK_STATE",
1255                "android.permission.POST_NOTIFICATIONS",
1256                "android.permission.VIBRATE",
1257            ]
1258        );
1259        assert_eq!(
1260            validation.manifest.deep_link_filters,
1261            ["scheme=weather;host=forecast.example;pathPrefix=/today"]
1262        );
1263        assert!(
1264            validation
1265                .manifest
1266                .components
1267                .contains(&format!("activity:{}", crate::ACTIVITY_CLASS))
1268        );
1269
1270        let wrong_host = String::from_utf8(xmltree.to_vec())
1271            .unwrap()
1272            .replace("forecast.example", "attacker.example");
1273        assert!(
1274            validate_aapt2_manifest(
1275                apk,
1276                wrong_host.as_bytes(),
1277                &expectation,
1278                &config,
1279                35,
1280                &mut validation,
1281            )
1282            .is_err()
1283        );
1284
1285        let xmltree = String::from_utf8(xmltree.to_vec()).unwrap();
1286        let uses_sdk = concat!(
1287            "      E: uses-sdk\n",
1288            "        A: http://schemas.android.com/apk/res/android:minSdkVersion=26\n",
1289            "        A: http://schemas.android.com/apk/res/android:targetSdkVersion=35\n",
1290        );
1291        for (actual, expected_error) in [
1292            (
1293                xmltree.replace("minSdkVersion=26", "minSdkVersion=25"),
1294                "attribute `minSdkVersion` expected `26`, found `25`",
1295            ),
1296            (
1297                xmltree.replace("targetSdkVersion=35", "targetSdkVersion=34"),
1298                "attribute `targetSdkVersion` expected `35`, found `34`",
1299            ),
1300            (
1301                xmltree.replace(uses_sdk, ""),
1302                "expected one `uses-sdk` element, found 0",
1303            ),
1304            (
1305                xmltree.replace(uses_sdk, &format!("{uses_sdk}{uses_sdk}")),
1306                "expected one `uses-sdk` element, found 2",
1307            ),
1308        ] {
1309            let error = validate_aapt2_manifest(
1310                apk,
1311                actual.as_bytes(),
1312                &expectation,
1313                &config,
1314                35,
1315                &mut validation,
1316            )
1317            .unwrap_err();
1318            assert!(error.to_string().contains(expected_error), "{error}");
1319        }
1320    }
1321
1322    #[test]
1323    fn validation_rejects_unexpected_native_libraries_and_noncanonical_dex_names() {
1324        let temp = TempDir::new().unwrap();
1325        let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1326        let apk = root.join("app.apk");
1327        let native = root.join("libweather.so");
1328        let unexpected = root.join("libother.so");
1329        base_apk(&apk);
1330        fs::write(&native, elf(AndroidAbi::Arm64V8a)).unwrap();
1331        fs::write(&unexpected, elf(AndroidAbi::X86_64)).unwrap();
1332        inject_apk_entries(
1333            &apk,
1334            &[
1335                NativeLibraryInput {
1336                    abi: AndroidAbi::Arm64V8a,
1337                    path: native,
1338                },
1339                NativeLibraryInput {
1340                    abi: AndroidAbi::X86_64,
1341                    path: unexpected,
1342                },
1343            ],
1344            &[],
1345        )
1346        .unwrap();
1347        let error = validate_apk_archive(
1348            &apk,
1349            &ApkExpectation {
1350                package_name: "com.example.weather".to_owned(),
1351                native_library_name: "weather".to_owned(),
1352                abis: vec![AndroidAbi::Arm64V8a],
1353                dex_required: false,
1354            },
1355        )
1356        .unwrap_err();
1357        assert!(error.to_string().contains("unexpected native library"));
1358        assert!(!valid_dex_name("classes02.dex"));
1359    }
1360}