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#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct NativeLibraryInput {
20 pub abi: AndroidAbi,
22 pub path: Utf8PathBuf,
24}
25
26#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct ApkExpectation {
29 pub package_name: String,
31 pub native_library_name: String,
33 pub abis: Vec<AndroidAbi>,
35 pub dex_required: bool,
37}
38
39#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41pub struct ApkValidation {
42 pub artifact_digest: ArtifactDigest,
44 pub package_name: String,
46 pub launcher_activity: String,
48 pub entries: Vec<String>,
50 pub native_abis: Vec<String>,
52 pub dex_files: usize,
54 pub manifest: Box<ManifestValidation>,
56}
57
58#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ManifestValidation {
61 pub permissions: Vec<String>,
63 pub components: Vec<String>,
65 pub deep_link_filters: Vec<String>,
67}
68
69pub 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
161pub 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
194pub 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
368pub 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
416pub 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}