1use std::{
2 collections::BTreeSet,
3 fs::{self, OpenOptions},
4 io::Read,
5 time::Duration,
6};
7
8use camino::{Utf8Path, Utf8PathBuf};
9use fs2::FileExt as _;
10use rustferry_codegen::generate_platform_assets;
11use rustferry_core::{AndroidAbi, AndroidLiveActivityFallback, FerryConfig, ProjectAssets, brand};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use crate::apk::apk_artifact_digest;
16use crate::command::external_tool_path_arg;
17use crate::error::io_error;
18use crate::{
19 AndroidError, AndroidSigningConfig, AndroidToolchain, ApkExpectation, ApkValidation,
20 CommandSpec, DiscoveryOptions, GeneratedAndroidContent, GeneratedAndroidFiles,
21 NativeLibraryInput, apksigner_sign_command, apksigner_verify_command, cargo_build_command,
22 collect_cargo_artifacts, collect_d8_outputs, collect_explicit_dex_inputs, discover_android,
23 generate::{content_from_generated_asset_set, generate_android_content_with_assets},
24 inject_apk_entries, preview_signing_config, resolve_signing_config, run_command,
25 validate_aapt2_badging, validate_aapt2_manifest, validate_apk_archive, write_android_content,
26};
27
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum AndroidBuildProfile {
32 #[default]
34 Debug,
35 Release,
37}
38
39impl AndroidBuildProfile {
40 fn directory(self) -> &'static str {
41 match self {
42 Self::Debug => "debug",
43 Self::Release => "release",
44 }
45 }
46}
47
48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum DexPolicy {
52 None,
54 #[default]
56 Required,
57}
58
59#[derive(Clone, Debug, PartialEq)]
61pub struct AndroidBuildRequest {
62 pub project_dir: Utf8PathBuf,
64 pub cargo_target_dir: Utf8PathBuf,
66 pub config: FerryConfig,
68 pub cargo_package_name: String,
70 pub cargo_library_target: String,
72 pub native_library_name: String,
74 pub profile: AndroidBuildProfile,
76 pub dex_policy: DexPolicy,
78 pub bridge_dex_inputs: Vec<Utf8PathBuf>,
80 pub signing: AndroidSigningConfig,
82 pub discovery: DiscoveryOptions,
84 pub dry_run: bool,
86 pub command_timeout: Duration,
88}
89
90impl AndroidBuildRequest {
91 pub fn new(
93 project_dir: impl Into<Utf8PathBuf>,
94 config: FerryConfig,
95 cargo_package_name: impl Into<String>,
96 cargo_library_target: impl Into<String>,
97 ) -> Self {
98 let project_dir = project_dir.into();
99 let cargo_library_target = cargo_library_target.into();
100 Self {
101 cargo_target_dir: project_dir.join("target"),
102 project_dir,
103 config,
104 cargo_package_name: cargo_package_name.into(),
105 native_library_name: cargo_library_target.replace('-', "_"),
106 cargo_library_target,
107 profile: AndroidBuildProfile::Debug,
108 dex_policy: DexPolicy::Required,
109 bridge_dex_inputs: Vec::new(),
110 signing: AndroidSigningConfig::default(),
111 discovery: DiscoveryOptions::default(),
112 dry_run: false,
113 command_timeout: crate::DEFAULT_COMMAND_TIMEOUT,
114 }
115 }
116}
117
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum AndroidPlanStepKind {
122 Generate,
124 Command,
126 Collect,
128 Package,
130 Verify,
132 Signing,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
138pub struct AndroidPlanStep {
139 pub stage: String,
141 pub kind: AndroidPlanStepKind,
143 pub detail: String,
145 pub command: Option<Vec<String>>,
147 pub outputs: Vec<Utf8PathBuf>,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
153pub struct AndroidBuildPaths {
154 pub root: Utf8PathBuf,
156 pub generated_root: Utf8PathBuf,
158 pub intermediates: Utf8PathBuf,
160 pub bridge_classes: Utf8PathBuf,
162 pub logs: Utf8PathBuf,
164 pub compiled_resources: Utf8PathBuf,
166 pub linked_apk: Utf8PathBuf,
168 pub assembled_apk: Utf8PathBuf,
170 pub aligned_apk: Utf8PathBuf,
172 pub final_apk: Utf8PathBuf,
174}
175
176#[derive(Clone, Debug, PartialEq)]
178pub struct AndroidBuildPlan {
179 pub toolchain: AndroidToolchain,
181 pub generated_content: GeneratedAndroidContent,
183 pub paths: AndroidBuildPaths,
185 pub cargo_commands: Vec<(AndroidAbi, CommandSpec)>,
187 pub steps: Vec<AndroidPlanStep>,
189}
190
191#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
193pub struct AndroidBuildArtifact {
194 apk: Utf8PathBuf,
196 validation: ApkValidation,
198 cache_hits: Vec<String>,
200 log_dir: Utf8PathBuf,
202}
203
204impl AndroidBuildArtifact {
205 #[must_use]
207 pub fn apk(&self) -> &Utf8Path {
208 &self.apk
209 }
210
211 #[must_use]
213 pub const fn validation(&self) -> &ApkValidation {
214 &self.validation
215 }
216
217 #[must_use]
219 pub fn cache_hits(&self) -> &[String] {
220 &self.cache_hits
221 }
222
223 #[must_use]
225 pub fn log_dir(&self) -> &Utf8Path {
226 &self.log_dir
227 }
228}
229
230#[derive(Clone, Debug, PartialEq)]
232#[allow(clippy::large_enum_variant)] pub enum AndroidBuildOutcome {
234 DryRun(Box<AndroidBuildPlan>),
236 Built(AndroidBuildArtifact),
238}
239
240pub fn build_android(request: &AndroidBuildRequest) -> Result<AndroidBuildOutcome, AndroidError> {
246 let discovery = discover_android(&request.discovery);
247 let toolchain = discovery.select_toolchain(&request.config.android)?;
248 let plan = plan_android_build(request, &toolchain)?;
249 if request.dry_run {
250 return Ok(AndroidBuildOutcome::DryRun(Box::new(plan)));
251 }
252 execute_android_build(request, &plan).map(AndroidBuildOutcome::Built)
253}
254
255pub fn plan_android_build(
261 request: &AndroidBuildRequest,
262 toolchain: &AndroidToolchain,
263) -> Result<AndroidBuildPlan, AndroidError> {
264 validate_request(request)?;
265 let d8 = toolchain
266 .build_tools
267 .d8
268 .clone()
269 .ok_or_else(|| AndroidError::ToolMissing {
270 tool: "d8".to_owned(),
271 searched: vec![toolchain.build_tools.directory.clone()],
272 fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
273 })?;
274 let has_dex = request.dex_policy == DexPolicy::Required;
275 let assets = ProjectAssets::load(&request.project_dir)?;
276 let generated_content = generate_android_content_with_assets(
277 &request.config,
278 &request.native_library_name,
279 toolchain.platform.api_level,
280 has_dex,
281 request.profile == AndroidBuildProfile::Debug,
282 &assets,
283 )?;
284 let build_fingerprint = build_fingerprint(&generated_content, request, toolchain);
285 let root = request
286 .cargo_target_dir
287 .join(brand::TARGET_DIRECTORY)
288 .join("android")
289 .join(request.profile.directory());
290 let intermediates = root.join("intermediates").join(&build_fingerprint);
291 let paths = AndroidBuildPaths {
292 generated_root: root.join("generated"),
293 logs: root.join("logs").join(&build_fingerprint),
294 compiled_resources: intermediates.join("compiled-resources.zip"),
295 linked_apk: intermediates.join("linked.apk"),
296 assembled_apk: intermediates.join("assembled.apk"),
297 aligned_apk: intermediates.join("aligned.apk"),
298 final_apk: root.join(format!("{}.apk", request.native_library_name)),
299 bridge_classes: intermediates.join("bridge-classes"),
300 root,
301 intermediates,
302 };
303 let mut cargo_commands = Vec::new();
304 let mut steps = vec![AndroidPlanStep {
305 stage: "generate Android manifest and resources".to_owned(),
306 kind: AndroidPlanStepKind::Generate,
307 detail: format!(
308 "content fingerprint {}; only enabled capabilities contribute permissions/components",
309 generated_content.fingerprint
310 ),
311 command: None,
312 outputs: vec![
313 paths
314 .generated_root
315 .join(&generated_content.fingerprint)
316 .join("AndroidManifest.xml"),
317 paths
318 .generated_root
319 .join(&generated_content.fingerprint)
320 .join("res"),
321 ],
322 }];
323 for abi in &request.config.android.abis {
324 let mut command = cargo_build_command(
325 toolchain,
326 &request.project_dir,
327 &request.cargo_target_dir,
328 &request.cargo_package_name,
329 *abi,
330 request.config.android.min_sdk,
331 request.profile == AndroidBuildProfile::Release,
332 )?;
333 command.timeout = request.command_timeout;
334 steps.push(command_step(
335 &command,
336 vec![request.cargo_target_dir.join(abi.rust_target())],
337 ));
338 cargo_commands.push((*abi, command));
339 }
340 steps.push(AndroidPlanStep {
341 stage: "collect Cargo native and dependency DEX outputs".to_owned(),
342 kind: AndroidPlanStepKind::Collect,
343 detail: "Parse compiler-artifact/build-script-executed JSON; recursively inspect only constrained OUT_DIRs; do not follow symlinks".to_owned(),
344 command: None,
345 outputs: vec![],
346 });
347
348 let generated_files = GeneratedAndroidFiles {
349 root: paths.generated_root.join(&generated_content.fingerprint),
350 manifest: paths
351 .generated_root
352 .join(&generated_content.fingerprint)
353 .join("AndroidManifest.xml"),
354 resources: paths
355 .generated_root
356 .join(&generated_content.fingerprint)
357 .join("res"),
358 java_sources: generated_content
359 .java_sources
360 .iter()
361 .map(|(relative, _)| {
362 paths
363 .generated_root
364 .join(&generated_content.fingerprint)
365 .join(relative)
366 })
367 .collect(),
368 };
369 let mut javac = javac_command(
370 toolchain,
371 &generated_files,
372 &paths.bridge_classes,
373 &paths.intermediates,
374 request.command_timeout,
375 )?;
376 javac.timeout = request.command_timeout;
377 steps.push(command_step(&javac, vec![paths.bridge_classes.clone()]));
378 let mut aapt_compile =
379 aapt2_compile_command(toolchain, &generated_files, &paths, request.command_timeout)?;
380 aapt_compile.timeout = request.command_timeout;
381 steps.push(command_step(
382 &aapt_compile,
383 vec![paths.compiled_resources.clone()],
384 ));
385 let mut aapt_link = aapt2_link_command(toolchain, request, &generated_files, &paths)?;
386 aapt_link.timeout = request.command_timeout;
387 steps.push(command_step(&aapt_link, vec![paths.linked_apk.clone()]));
388 if request.dex_policy == DexPolicy::Required {
389 steps.push(AndroidPlanStep {
390 stage: "merge dependency and bridge DEX".to_owned(),
391 kind: AndroidPlanStepKind::Command,
392 detail: "D8 inputs resolve after Cargo JSON parsing; absence is a typed MissingDex error listing every searched OUT_DIR".to_owned(),
393 command: Some(vec![
394 d8.to_string(),
395 "--min-api".to_owned(),
396 request.config.android.min_sdk.to_string(),
397 "--output".to_owned(),
398 "<content-addressed-dex-directory>".to_owned(),
399 "<cargo-out-dir-and-bridge-dex-inputs>".to_owned(),
400 ]),
401 outputs: vec![paths.intermediates.join("dex/<input-fingerprint>/classes.dex")],
402 });
403 }
404 steps.push(AndroidPlanStep {
405 stage: "inject native libraries and DEX".to_owned(),
406 kind: AndroidPlanStepKind::Package,
407 detail: "Append only validated, stored lib/<abi>/lib*.so and classes*.dex entries; reject duplicates and unsafe names".to_owned(),
408 command: None,
409 outputs: vec![paths.assembled_apk.clone()],
410 });
411 let mut align = zipalign_command(
412 toolchain,
413 &paths.assembled_apk,
414 &paths.aligned_apk,
415 &request.project_dir,
416 false,
417 )?;
418 align.timeout = request.command_timeout;
419 steps.push(command_step(&align, vec![paths.aligned_apk.clone()]));
420 steps.push(AndroidPlanStep {
421 stage: "prepare signing identity".to_owned(),
422 kind: AndroidPlanStepKind::Signing,
423 detail: "Create/validate the persistent debug PKCS#12 key under the OS config directory, or validate explicit password references; never place password values in argv/logs".to_owned(),
424 command: None,
425 outputs: vec![],
426 });
427 let preview_signing = preview_signing_config(&request.signing)?;
428 let mut sign = apksigner_sign_command(
429 toolchain,
430 &preview_signing,
431 &paths.aligned_apk,
432 &paths.final_apk,
433 &request.project_dir,
434 )?;
435 sign.timeout = request.command_timeout;
436 steps.push(command_step(&sign, vec![paths.final_apk.clone()]));
437 for mut verification in [
438 apksigner_verify_command(toolchain, &paths.final_apk, &request.project_dir)?,
439 zipalign_command(
440 toolchain,
441 &paths.final_apk,
442 &paths.final_apk,
443 &request.project_dir,
444 true,
445 )?,
446 aapt2_badging_command(toolchain, &paths.final_apk, &request.project_dir)?,
447 aapt2_manifest_command(toolchain, &paths.final_apk, &request.project_dir)?,
448 ] {
449 verification.timeout = request.command_timeout;
450 steps.push(command_step(&verification, vec![]));
451 }
452 steps.push(AndroidPlanStep {
453 stage: "independently validate APK ZIP, DEX, and ELF entries".to_owned(),
454 kind: AndroidPlanStepKind::Verify,
455 detail: "Reject traversal/duplicates; require manifest/resources/icon; validate DEX magic, stored libraries, ELF type/class/machine, exact ABI set".to_owned(),
456 command: None,
457 outputs: vec![],
458 });
459 Ok(AndroidBuildPlan {
460 toolchain: toolchain.clone(),
461 generated_content,
462 paths,
463 cargo_commands,
464 steps,
465 })
466}
467
468fn execute_android_build(
469 request: &AndroidBuildRequest,
470 plan: &AndroidBuildPlan,
471) -> Result<AndroidBuildArtifact, AndroidError> {
472 prepare_android_output_root(&request.cargo_target_dir, &plan.paths.root)?;
473 let lock_path = plan.paths.root.join(".build.lock");
474 let build_lock = OpenOptions::new()
475 .create(true)
476 .truncate(false)
477 .read(true)
478 .write(true)
479 .open(&lock_path)
480 .map_err(|source| io_error("open Android build lock", &lock_path, source))?;
481 build_lock
482 .lock_exclusive()
483 .map_err(|source| io_error("lock Android build output", &lock_path, source))?;
484 fs::create_dir_all(&plan.paths.intermediates).map_err(|source| {
485 io_error(
486 "create Android intermediates directory",
487 &plan.paths.intermediates,
488 source,
489 )
490 })?;
491 fs::create_dir_all(&plan.paths.logs)
492 .map_err(|source| io_error("create Android log directory", &plan.paths.logs, source))?;
493 let asset_set = generate_platform_assets(&request.project_dir, None)?;
494 let generated_content = content_from_generated_asset_set(&plan.generated_content, &asset_set)?;
495 let generated = write_android_content(&plan.paths.generated_root, &generated_content)?;
496 let bridge_class_files = compile_bridge_classes(request, plan, &generated)?;
497 let mut natives = Vec::new();
498 let mut dependency_dex = bridge_class_files;
499 let mut searched_out_dirs = Vec::new();
500 for (abi, command) in &plan.cargo_commands {
501 let output = run_command(
502 command,
503 &plan
504 .paths
505 .logs
506 .join(format!("cargo-{}.log", abi.apk_directory())),
507 )?;
508 let artifacts = collect_cargo_artifacts(
509 &output.stdout,
510 abi.rust_target(),
511 &request.cargo_library_target,
512 &request.cargo_target_dir,
513 )?;
514 natives.push(NativeLibraryInput {
515 abi: *abi,
516 path: artifacts.native_library,
517 });
518 dependency_dex.extend(artifacts.dependency_dex_files);
519 searched_out_dirs.extend(artifacts.build_script_out_dirs);
520 }
521 searched_out_dirs.sort();
522 searched_out_dirs.dedup();
523 let explicit = collect_explicit_dex_inputs(&request.bridge_dex_inputs)?;
524 dependency_dex.extend(explicit);
525 dependency_dex = deduplicate_files_by_content(dependency_dex)?;
526 if request.dex_policy == DexPolicy::Required && dependency_dex.is_empty() {
527 return Err(AndroidError::MissingDex {
528 searched: searched_out_dirs,
529 });
530 }
531
532 let mut cache_hits = Vec::new();
533 let compiled_resources_marker = plan.paths.intermediates.join("aapt2-compile.sha256");
534 if cached_file_matches(&plan.paths.compiled_resources, &compiled_resources_marker) {
535 cache_hits.push("aapt2-compile".to_owned());
536 } else {
537 remove_stale_file(&plan.paths.compiled_resources)?;
538 let command = aapt2_compile_command(
539 &plan.toolchain,
540 &generated,
541 &plan.paths,
542 request.command_timeout,
543 )?;
544 run_command(&command, &plan.paths.logs.join("aapt2-compile.log"))?;
545 write_file_cache_marker(&plan.paths.compiled_resources, &compiled_resources_marker)?;
546 }
547 let linked_apk_marker = plan.paths.intermediates.join("aapt2-link.sha256");
548 if cached_file_matches(&plan.paths.linked_apk, &linked_apk_marker) {
549 cache_hits.push("aapt2-link".to_owned());
550 } else {
551 remove_stale_file(&plan.paths.linked_apk)?;
552 let mut command = aapt2_link_command(&plan.toolchain, request, &generated, &plan.paths)?;
553 command.timeout = request.command_timeout;
554 run_command(&command, &plan.paths.logs.join("aapt2-link.log"))?;
555 write_file_cache_marker(&plan.paths.linked_apk, &linked_apk_marker)?;
556 }
557
558 let dex_files = if dependency_dex.is_empty() {
559 Vec::new()
560 } else {
561 let fingerprint = file_set_fingerprint(&dependency_dex)?;
562 let dex_root = plan.paths.intermediates.join("dex");
563 let directory = dex_root.join(fingerprint);
564 if let Some(outputs) = cached_d8_outputs(&directory) {
565 cache_hits.push("d8".to_owned());
566 outputs
567 } else {
568 reset_d8_cache_directory(&directory, &dex_root)?;
569 let mut command = d8_command(&plan.toolchain, request, &dependency_dex, &directory)?;
570 command.timeout = request.command_timeout;
571 run_command(&command, &plan.paths.logs.join("d8.log"))?;
572 let outputs = collect_d8_outputs(&directory)?;
573 write_d8_cache_marker(&directory, &outputs)?;
574 outputs
575 }
576 };
577 validate_required_dex_components(request, &dex_files)?;
578
579 fs::copy(&plan.paths.linked_apk, &plan.paths.assembled_apk).map_err(|source| {
580 io_error(
581 "copy resource-linked APK for injection",
582 &plan.paths.assembled_apk,
583 source,
584 )
585 })?;
586 inject_apk_entries(&plan.paths.assembled_apk, &natives, &dex_files)?;
587 let mut align = zipalign_command(
588 &plan.toolchain,
589 &plan.paths.assembled_apk,
590 &plan.paths.aligned_apk,
591 &request.project_dir,
592 false,
593 )?;
594 align.timeout = request.command_timeout;
595 run_command(&align, &plan.paths.logs.join("zipalign.log"))?;
596
597 let signing = resolve_signing_config(&request.signing, &plan.toolchain, &plan.paths.logs)?;
598 let mut sign = apksigner_sign_command(
599 &plan.toolchain,
600 &signing,
601 &plan.paths.aligned_apk,
602 &plan.paths.final_apk,
603 &request.project_dir,
604 )?;
605 sign.timeout = request.command_timeout;
606 run_command(&sign, &plan.paths.logs.join("apksigner-sign.log"))?;
607
608 let validated_digest = apk_artifact_digest(&plan.paths.final_apk)?;
609
610 let mut signature =
611 apksigner_verify_command(&plan.toolchain, &plan.paths.final_apk, &request.project_dir)?;
612 signature.timeout = request.command_timeout;
613 run_command(&signature, &plan.paths.logs.join("apksigner-verify.log"))?;
614 let mut alignment = zipalign_command(
615 &plan.toolchain,
616 &plan.paths.final_apk,
617 &plan.paths.final_apk,
618 &request.project_dir,
619 true,
620 )?;
621 alignment.timeout = request.command_timeout;
622 run_command(&alignment, &plan.paths.logs.join("zipalign-verify.log"))?;
623
624 let expectation = ApkExpectation {
625 package_name: request.config.app.identifier.clone(),
626 native_library_name: request.native_library_name.clone(),
627 abis: request.config.android.abis.clone(),
628 dex_required: request.dex_policy == DexPolicy::Required,
629 };
630 let mut validation = validate_apk_archive(&plan.paths.final_apk, &expectation)?;
631 let mut badging =
632 aapt2_badging_command(&plan.toolchain, &plan.paths.final_apk, &request.project_dir)?;
633 badging.timeout = request.command_timeout;
634 let output = run_command(&badging, &plan.paths.logs.join("aapt2-badging.log"))?;
635 validate_aapt2_badging(
636 &plan.paths.final_apk,
637 &output.stdout,
638 &expectation,
639 &mut validation,
640 )?;
641 let mut manifest =
642 aapt2_manifest_command(&plan.toolchain, &plan.paths.final_apk, &request.project_dir)?;
643 manifest.timeout = request.command_timeout;
644 let output = run_command(&manifest, &plan.paths.logs.join("aapt2-manifest.log"))?;
645 validate_aapt2_manifest(
646 &plan.paths.final_apk,
647 &output.stdout,
648 &expectation,
649 &request.config,
650 plan.toolchain.platform.api_level,
651 &mut validation,
652 )?;
653 let current_digest = apk_artifact_digest(&plan.paths.final_apk)?;
654 if validation.artifact_digest != validated_digest || current_digest != validated_digest {
655 return Err(AndroidError::InvalidArtifact {
656 path: plan.paths.final_apk.clone(),
657 reason: "APK changed while signature, alignment, archive, or manifest validation was running"
658 .to_owned(),
659 });
660 }
661 Ok(AndroidBuildArtifact {
662 apk: plan.paths.final_apk.clone(),
663 validation,
664 cache_hits,
665 log_dir: plan.paths.logs.clone(),
666 })
667}
668
669fn validate_request(request: &AndroidBuildRequest) -> Result<(), AndroidError> {
670 request
671 .config
672 .validate_or_error()
673 .map_err(|error| AndroidError::InvalidConfig(error.to_string()))?;
674 if !request.project_dir.join("Cargo.toml").is_file() {
675 return Err(AndroidError::InvalidRequest(format!(
676 "project Cargo.toml was not found at {}",
677 request.project_dir.join("Cargo.toml")
678 )));
679 }
680 for (field, value) in [
681 ("cargo package name", request.cargo_package_name.as_str()),
682 (
683 "Cargo library target",
684 request.cargo_library_target.as_str(),
685 ),
686 ] {
687 if value.trim().is_empty() || value.chars().any(char::is_control) {
688 return Err(AndroidError::InvalidRequest(format!(
689 "{field} cannot be empty or contain control characters"
690 )));
691 }
692 }
693 if request.native_library_name.is_empty()
694 || !request
695 .native_library_name
696 .chars()
697 .all(|character| character.is_ascii_alphanumeric() || character == '_')
698 {
699 return Err(AndroidError::InvalidRequest(format!(
700 "native library name `{}` is unsafe",
701 request.native_library_name
702 )));
703 }
704 if request.dex_policy == DexPolicy::None {
705 return Err(AndroidError::InvalidRequest(
706 "the mandatory Android runtime bridge requires DexPolicy::Required".to_owned(),
707 ));
708 }
709 if request.command_timeout.is_zero() {
710 return Err(AndroidError::InvalidRequest(
711 "command timeout must be greater than zero".to_owned(),
712 ));
713 }
714 Ok(())
715}
716
717fn javac_command(
718 toolchain: &AndroidToolchain,
719 generated: &GeneratedAndroidFiles,
720 output: &Utf8Path,
721 current_dir: &Utf8Path,
722 timeout: Duration,
723) -> Result<CommandSpec, AndroidError> {
724 let javac = toolchain
725 .javac
726 .clone()
727 .ok_or_else(|| AndroidError::ToolMissing {
728 tool: "javac".to_owned(),
729 searched: Vec::new(),
730 fix: "Install a JDK and set JAVA_HOME, then run `cargo ferry doctor`.".to_owned(),
731 })?;
732 let mut command = CommandSpec::new("compile generated Android bridge", javac, current_dir);
733 command.args = vec![
734 "-encoding".to_owned(),
735 "UTF-8".to_owned(),
736 "-source".to_owned(),
737 "8".to_owned(),
738 "-target".to_owned(),
739 "8".to_owned(),
740 "-Xlint:-options".to_owned(),
741 "-bootclasspath".to_owned(),
742 external_tool_path_arg(&toolchain.platform.android_jar)?,
743 "-d".to_owned(),
744 external_tool_path_arg(output)?,
745 ];
746 command.args.extend(
747 generated
748 .java_sources
749 .iter()
750 .map(|path| external_tool_path_arg(path))
751 .collect::<Result<Vec<_>, _>>()?,
752 );
753 command.timeout = timeout;
754 Ok(command)
755}
756
757fn compile_bridge_classes(
758 request: &AndroidBuildRequest,
759 plan: &AndroidBuildPlan,
760 generated: &GeneratedAndroidFiles,
761) -> Result<Vec<Utf8PathBuf>, AndroidError> {
762 let marker = plan.paths.bridge_classes.join(".complete.sha256");
763 if let Ok(classes) =
764 collect_explicit_dex_inputs(std::slice::from_ref(&plan.paths.bridge_classes))
765 && !classes.is_empty()
766 && fs::read_to_string(&marker).ok().is_some_and(|expected| {
767 file_set_fingerprint(&classes).ok().as_deref() == Some(expected.trim())
768 })
769 {
770 return Ok(classes);
771 }
772 reset_bridge_classes_directory(&plan.paths.bridge_classes, &plan.paths.intermediates)?;
773 let command = javac_command(
774 &plan.toolchain,
775 generated,
776 &plan.paths.bridge_classes,
777 &plan.paths.intermediates,
778 request.command_timeout,
779 )?;
780 run_command(&command, &plan.paths.logs.join("javac.log"))?;
781 let classes = collect_explicit_dex_inputs(std::slice::from_ref(&plan.paths.bridge_classes))?;
782 if classes.is_empty() {
783 return Err(AndroidError::MissingDex {
784 searched: vec![plan.paths.bridge_classes.clone()],
785 });
786 }
787 let digest = file_set_fingerprint(&classes)?;
788 fs::write(&marker, format!("{digest}\n"))
789 .map_err(|source| io_error("write Java bridge cache marker", &marker, source))?;
790 Ok(classes)
791}
792
793fn reset_bridge_classes_directory(
794 directory: &Utf8Path,
795 expected_parent: &Utf8Path,
796) -> Result<(), AndroidError> {
797 if directory.parent() != Some(expected_parent)
798 || directory.file_name() != Some("bridge-classes")
799 {
800 return Err(AndroidError::InvalidRequest(format!(
801 "refusing to reset unsafe Java bridge path `{directory}`"
802 )));
803 }
804 ensure_owned_directory(expected_parent, "Java bridge parent")?;
805 match fs::symlink_metadata(directory) {
806 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
807 fs::remove_file(directory)
808 .map_err(|source| io_error("remove stale Java bridge path", directory, source))?;
809 }
810 Ok(metadata) if metadata.is_dir() => {
811 fs::remove_dir_all(directory).map_err(|source| {
812 io_error("remove stale Java bridge directory", directory, source)
813 })?;
814 }
815 Ok(_) => {
816 return Err(AndroidError::InvalidRequest(format!(
817 "Java bridge path has an unsupported file type: {directory}"
818 )));
819 }
820 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
821 Err(source) => {
822 return Err(io_error("inspect Java bridge directory", directory, source));
823 }
824 }
825 fs::create_dir_all(directory)
826 .map_err(|source| io_error("create Java bridge directory", directory, source))
827}
828
829fn aapt2_compile_command(
830 toolchain: &AndroidToolchain,
831 generated: &GeneratedAndroidFiles,
832 paths: &AndroidBuildPaths,
833 timeout: Duration,
834) -> Result<CommandSpec, AndroidError> {
835 let mut command = CommandSpec::new(
836 "compile Android resources",
837 required_build_tool(toolchain, "aapt2", toolchain.build_tools.aapt2.as_ref())?,
838 &paths.intermediates,
839 );
840 command.args = vec![
841 "compile".to_owned(),
842 "--no-crunch".to_owned(),
843 "--dir".to_owned(),
844 generated.resources.to_string(),
845 "-o".to_owned(),
846 paths.compiled_resources.to_string(),
847 ];
848 command.timeout = timeout;
849 Ok(command)
850}
851
852fn aapt2_link_command(
853 toolchain: &AndroidToolchain,
854 request: &AndroidBuildRequest,
855 generated: &GeneratedAndroidFiles,
856 paths: &AndroidBuildPaths,
857) -> Result<CommandSpec, AndroidError> {
858 let mut command = CommandSpec::new(
859 "link Android resources",
860 required_build_tool(toolchain, "aapt2", toolchain.build_tools.aapt2.as_ref())?,
861 &paths.intermediates,
862 );
863 command.args = vec![
864 "link".to_owned(),
865 "-o".to_owned(),
866 paths.linked_apk.to_string(),
867 "-I".to_owned(),
868 toolchain.platform.android_jar.to_string(),
869 "--manifest".to_owned(),
870 generated.manifest.to_string(),
871 "--min-sdk-version".to_owned(),
872 request.config.android.min_sdk.to_string(),
873 "--target-sdk-version".to_owned(),
874 toolchain.platform.api_level.to_string(),
875 "--version-code".to_owned(),
876 version_code(request)?.to_string(),
877 "--version-name".to_owned(),
878 request.config.app.display_version.clone(),
879 paths.compiled_resources.to_string(),
880 ];
881 Ok(command)
882}
883
884fn d8_command(
885 toolchain: &AndroidToolchain,
886 request: &AndroidBuildRequest,
887 inputs: &[Utf8PathBuf],
888 output: &Utf8Path,
889) -> Result<CommandSpec, AndroidError> {
890 let mut command = CommandSpec::new(
891 "merge dependency and bridge DEX",
892 required_build_tool(toolchain, "d8", toolchain.build_tools.d8.as_ref())?,
893 &request.project_dir,
894 );
895 command.args = vec![
896 "--min-api".to_owned(),
897 request.config.android.min_sdk.to_string(),
898 "--lib".to_owned(),
899 external_tool_path_arg(&toolchain.platform.android_jar)?,
900 "--output".to_owned(),
901 external_tool_path_arg(output)?,
902 ];
903 command.args.extend(
904 inputs
905 .iter()
906 .map(|path| external_tool_path_arg(path))
907 .collect::<Result<Vec<_>, _>>()?,
908 );
909 Ok(command)
910}
911
912fn zipalign_command(
913 toolchain: &AndroidToolchain,
914 input: &Utf8Path,
915 output: &Utf8Path,
916 current_dir: &Utf8Path,
917 verify: bool,
918) -> Result<CommandSpec, AndroidError> {
919 let mut command = CommandSpec::new(
920 if verify {
921 "verify APK alignment"
922 } else {
923 "align APK"
924 },
925 required_build_tool(
926 toolchain,
927 "zipalign",
928 toolchain.build_tools.zipalign.as_ref(),
929 )?,
930 current_dir,
931 );
932 command.args = if verify {
933 vec![
934 "-c".to_owned(),
935 "-P".to_owned(),
936 "16".to_owned(),
937 "4".to_owned(),
938 input.to_string(),
939 ]
940 } else {
941 vec![
942 "-P".to_owned(),
943 "16".to_owned(),
944 "-f".to_owned(),
945 "4".to_owned(),
946 input.to_string(),
947 output.to_string(),
948 ]
949 };
950 Ok(command)
951}
952
953fn aapt2_badging_command(
954 toolchain: &AndroidToolchain,
955 apk: &Utf8Path,
956 current_dir: &Utf8Path,
957) -> Result<CommandSpec, AndroidError> {
958 let mut command = CommandSpec::new(
959 "inspect APK package metadata",
960 required_build_tool(toolchain, "aapt2", toolchain.build_tools.aapt2.as_ref())?,
961 current_dir,
962 );
963 command.args = vec!["dump".to_owned(), "badging".to_owned(), apk.to_string()];
964 Ok(command)
965}
966
967fn aapt2_manifest_command(
968 toolchain: &AndroidToolchain,
969 apk: &Utf8Path,
970 current_dir: &Utf8Path,
971) -> Result<CommandSpec, AndroidError> {
972 let mut command = CommandSpec::new(
973 "inspect compiled Android manifest",
974 required_build_tool(toolchain, "aapt2", toolchain.build_tools.aapt2.as_ref())?,
975 current_dir,
976 );
977 command.args = vec![
978 "dump".to_owned(),
979 "xmltree".to_owned(),
980 "--file".to_owned(),
981 "AndroidManifest.xml".to_owned(),
982 apk.to_string(),
983 ];
984 Ok(command)
985}
986
987fn required_build_tool(
988 toolchain: &AndroidToolchain,
989 name: &str,
990 path: Option<&Utf8PathBuf>,
991) -> Result<Utf8PathBuf, AndroidError> {
992 path.cloned().ok_or_else(|| AndroidError::ToolMissing {
993 tool: name.to_owned(),
994 searched: vec![toolchain.build_tools.directory.clone()],
995 fix: "Install a complete Android SDK Build Tools revision.".to_owned(),
996 })
997}
998
999fn command_step(command: &CommandSpec, outputs: Vec<Utf8PathBuf>) -> AndroidPlanStep {
1000 AndroidPlanStep {
1001 stage: command.stage.clone(),
1002 kind: AndroidPlanStepKind::Command,
1003 detail: format!("cwd: {}", command.current_dir),
1004 command: Some(command.redacted_argv()),
1005 outputs,
1006 }
1007}
1008
1009fn version_code(request: &AndroidBuildRequest) -> Result<u32, AndroidError> {
1010 let version = &request.config.app.version;
1011 let value = version
1012 .major
1013 .checked_mul(1_000_000)
1014 .and_then(|value| {
1015 version
1016 .minor
1017 .checked_mul(1_000)
1018 .and_then(|minor| value.checked_add(minor))
1019 })
1020 .and_then(|value| value.checked_add(version.patch))
1021 .filter(|value| *value > 0 && i32::try_from(*value).is_ok())
1022 .ok_or_else(|| {
1023 AndroidError::InvalidRequest(format!(
1024 "app.version `{version}` cannot be represented as a positive Android versionCode"
1025 ))
1026 })?;
1027 u32::try_from(value).map_err(|_| {
1028 AndroidError::InvalidRequest(format!(
1029 "app.version `{version}` cannot be represented as an Android versionCode"
1030 ))
1031 })
1032}
1033
1034fn build_fingerprint(
1035 content: &GeneratedAndroidContent,
1036 request: &AndroidBuildRequest,
1037 toolchain: &AndroidToolchain,
1038) -> String {
1039 let mut hasher = Sha256::new();
1040 hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
1041 hasher.update(b"direct-android-pipeline-v1");
1042 hasher.update(content.fingerprint.as_bytes());
1043 hasher.update(toolchain.build_tools.version.as_bytes());
1044 hasher.update(toolchain.platform.api_level.to_le_bytes());
1045 hasher.update(toolchain.ndk.version.as_bytes());
1046 hasher.update(request.cargo_package_name.as_bytes());
1047 hasher.update(request.cargo_library_target.as_bytes());
1048 for abi in &request.config.android.abis {
1049 hasher.update(abi.rust_target().as_bytes());
1050 }
1051 hex::encode(&hasher.finalize()[..12])
1052}
1053
1054fn validate_required_dex_components(
1055 request: &AndroidBuildRequest,
1056 dex_files: &[Utf8PathBuf],
1057) -> Result<(), AndroidError> {
1058 let live_fallback = request.config.extensions.live_activity.enabled
1059 && request.config.extensions.live_activity.android_fallback
1060 == AndroidLiveActivityFallback::OngoingNotification;
1061 let mut required = vec![crate::ACTIVITY_CLASS, crate::BRIDGE_CLASS];
1062 if request.config.capabilities.notifications.local || live_fallback {
1063 required.push(crate::NOTIFICATION_RECEIVER_CLASS);
1064 }
1065 if request.config.extensions.widget.enabled {
1066 required.push(crate::WIDGET_PROVIDER_CLASS);
1067 }
1068 if request.config.capabilities.share.enabled {
1069 required.push(crate::FILE_PROVIDER_CLASS);
1070 }
1071 let files = dex_files
1072 .iter()
1073 .map(|path| {
1074 fs::read(path)
1075 .map(|bytes| (path, bytes))
1076 .map_err(|source| io_error("read DEX for component validation", path, source))
1077 })
1078 .collect::<Result<Vec<_>, _>>()?;
1079 for class_name in required {
1080 let descriptor = format!("L{};", class_name.replace('.', "/"));
1081 if files
1082 .iter()
1083 .any(|(_, bytes)| dex_defines_class(bytes, descriptor.as_bytes()))
1084 {
1085 continue;
1086 }
1087 return Err(AndroidError::MissingDexClass {
1088 class_name: class_name.to_owned(),
1089 searched: dex_files.to_vec(),
1090 });
1091 }
1092 Ok(())
1093}
1094
1095fn dex_defines_class(dex: &[u8], descriptor: &[u8]) -> bool {
1096 if dex.len() < 112 || &dex[..4] != b"dex\n" {
1097 return false;
1098 }
1099 let Some(string_ids_size) = dex_u32(dex, 56).and_then(|value| usize::try_from(value).ok())
1100 else {
1101 return false;
1102 };
1103 let Some(string_ids_off) = dex_u32(dex, 60).and_then(|value| usize::try_from(value).ok())
1104 else {
1105 return false;
1106 };
1107 let Some(type_ids_size) = dex_u32(dex, 64).and_then(|value| usize::try_from(value).ok()) else {
1108 return false;
1109 };
1110 let Some(type_ids_off) = dex_u32(dex, 68).and_then(|value| usize::try_from(value).ok()) else {
1111 return false;
1112 };
1113 let Some(class_defs_size) = dex_u32(dex, 96).and_then(|value| usize::try_from(value).ok())
1114 else {
1115 return false;
1116 };
1117 let Some(class_defs_off) = dex_u32(dex, 100).and_then(|value| usize::try_from(value).ok())
1118 else {
1119 return false;
1120 };
1121 for class_index in 0..class_defs_size {
1122 let Some(class_offset) = class_index
1123 .checked_mul(32)
1124 .and_then(|offset| class_defs_off.checked_add(offset))
1125 else {
1126 return false;
1127 };
1128 let Some(type_index) =
1129 dex_u32(dex, class_offset).and_then(|value| usize::try_from(value).ok())
1130 else {
1131 return false;
1132 };
1133 if type_index >= type_ids_size {
1134 return false;
1135 }
1136 let Some(type_offset) = type_index
1137 .checked_mul(4)
1138 .and_then(|offset| type_ids_off.checked_add(offset))
1139 else {
1140 return false;
1141 };
1142 let Some(string_index) =
1143 dex_u32(dex, type_offset).and_then(|value| usize::try_from(value).ok())
1144 else {
1145 return false;
1146 };
1147 if string_index >= string_ids_size {
1148 return false;
1149 }
1150 let Some(string_offset) = string_index
1151 .checked_mul(4)
1152 .and_then(|offset| string_ids_off.checked_add(offset))
1153 else {
1154 return false;
1155 };
1156 let Some(data_offset) =
1157 dex_u32(dex, string_offset).and_then(|value| usize::try_from(value).ok())
1158 else {
1159 return false;
1160 };
1161 let Some(text_start) = dex_string_start(dex, data_offset) else {
1162 return false;
1163 };
1164 let Some(text_end) = dex[text_start..]
1165 .iter()
1166 .position(|byte| *byte == 0)
1167 .and_then(|length| text_start.checked_add(length))
1168 else {
1169 return false;
1170 };
1171 if &dex[text_start..text_end] == descriptor {
1172 return true;
1173 }
1174 }
1175 false
1176}
1177
1178fn dex_u32(dex: &[u8], offset: usize) -> Option<u32> {
1179 let bytes: [u8; 4] = dex.get(offset..offset.checked_add(4)?)?.try_into().ok()?;
1180 Some(u32::from_le_bytes(bytes))
1181}
1182
1183fn dex_string_start(dex: &[u8], mut offset: usize) -> Option<usize> {
1184 for _ in 0..5 {
1185 let byte = *dex.get(offset)?;
1186 offset = offset.checked_add(1)?;
1187 if byte & 0x80 == 0 {
1188 return Some(offset);
1189 }
1190 }
1191 None
1192}
1193
1194fn cached_file_matches(output: &Utf8Path, marker: &Utf8Path) -> bool {
1195 let Ok(expected) = fs::read_to_string(marker) else {
1196 return false;
1197 };
1198 let Ok(actual) = file_fingerprint(output) else {
1199 return false;
1200 };
1201 expected.trim() == hex::encode(actual)
1202}
1203
1204fn write_file_cache_marker(output: &Utf8Path, marker: &Utf8Path) -> Result<(), AndroidError> {
1205 let digest = hex::encode(file_fingerprint(output)?);
1206 fs::write(marker, format!("{digest}\n"))
1207 .map_err(|source| io_error("write Android cache marker", marker, source))
1208}
1209
1210fn remove_stale_file(path: &Utf8Path) -> Result<(), AndroidError> {
1211 match fs::remove_file(path) {
1212 Ok(()) => Ok(()),
1213 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
1214 Err(source) => Err(io_error("remove stale Android intermediate", path, source)),
1215 }
1216}
1217
1218fn cached_d8_outputs(directory: &Utf8Path) -> Option<Vec<Utf8PathBuf>> {
1219 let outputs = collect_d8_outputs(directory).ok()?;
1220 let expected = fs::read_to_string(directory.join(".complete.sha256")).ok()?;
1221 let actual = file_set_fingerprint(&outputs).ok()?;
1222 (expected.trim() == actual).then_some(outputs)
1223}
1224
1225fn write_d8_cache_marker(
1226 directory: &Utf8Path,
1227 outputs: &[Utf8PathBuf],
1228) -> Result<(), AndroidError> {
1229 let digest = file_set_fingerprint(outputs)?;
1230 let marker = directory.join(".complete.sha256");
1231 fs::write(&marker, format!("{digest}\n"))
1232 .map_err(|source| io_error("write D8 cache marker", marker, source))
1233}
1234
1235fn reset_d8_cache_directory(
1236 directory: &Utf8Path,
1237 expected_parent: &Utf8Path,
1238) -> Result<(), AndroidError> {
1239 let fingerprint = directory.file_name().unwrap_or_default();
1240 if directory.parent() != Some(expected_parent)
1241 || fingerprint.len() != 24
1242 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())
1243 {
1244 return Err(AndroidError::InvalidRequest(format!(
1245 "refusing to reset unsafe D8 cache path `{directory}`"
1246 )));
1247 }
1248 ensure_owned_directory(expected_parent, "D8 cache parent")?;
1249 match fs::symlink_metadata(directory) {
1250 Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
1251 fs::remove_file(directory)
1252 .map_err(|source| io_error("remove stale D8 cache path", directory, source))?;
1253 }
1254 Ok(metadata) if metadata.is_dir() => {
1255 fs::remove_dir_all(directory)
1256 .map_err(|source| io_error("remove stale D8 cache directory", directory, source))?;
1257 }
1258 Ok(_) => {
1259 return Err(AndroidError::InvalidRequest(format!(
1260 "D8 cache path has an unsupported file type: {directory}"
1261 )));
1262 }
1263 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
1264 Err(source) => {
1265 return Err(io_error(
1266 "inspect stale D8 cache directory",
1267 directory,
1268 source,
1269 ));
1270 }
1271 }
1272 fs::create_dir_all(directory)
1273 .map_err(|source| io_error("create D8 output directory", directory, source))
1274}
1275
1276fn prepare_android_output_root(
1277 cargo_target_dir: &Utf8Path,
1278 output_root: &Utf8Path,
1279) -> Result<(), AndroidError> {
1280 if !output_root.starts_with(cargo_target_dir) {
1281 return Err(AndroidError::InvalidRequest(format!(
1282 "Android output `{output_root}` is outside Cargo target directory `{cargo_target_dir}`"
1283 )));
1284 }
1285 match fs::symlink_metadata(cargo_target_dir) {
1286 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1287 return Err(AndroidError::InvalidRequest(format!(
1288 "Cargo target directory must be a real directory, not a symlink: `{cargo_target_dir}`"
1289 )));
1290 }
1291 Ok(_) => {}
1292 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1293 fs::create_dir_all(cargo_target_dir).map_err(|source| {
1294 io_error("create Cargo target directory", cargo_target_dir, source)
1295 })?;
1296 }
1297 Err(source) => {
1298 return Err(io_error(
1299 "inspect Cargo target directory",
1300 cargo_target_dir,
1301 source,
1302 ));
1303 }
1304 }
1305
1306 let mut current = cargo_target_dir.to_owned();
1307 let relative = output_root.strip_prefix(cargo_target_dir).map_err(|_| {
1308 AndroidError::InvalidRequest(format!(
1309 "Android output `{output_root}` is outside Cargo target directory `{cargo_target_dir}`"
1310 ))
1311 })?;
1312 for component in relative.components() {
1313 current.push(component.as_str());
1314 ensure_owned_directory(¤t, "Android output component")?;
1315 }
1316
1317 let canonical_target = cargo_target_dir.canonicalize_utf8().map_err(|source| {
1318 io_error(
1319 "canonicalize Cargo target directory",
1320 cargo_target_dir,
1321 source,
1322 )
1323 })?;
1324 let canonical_output = output_root
1325 .canonicalize_utf8()
1326 .map_err(|source| io_error("canonicalize Android output directory", output_root, source))?;
1327 if !canonical_output.starts_with(&canonical_target) {
1328 return Err(AndroidError::InvalidRequest(format!(
1329 "Android output `{output_root}` escaped Cargo target directory `{cargo_target_dir}`"
1330 )));
1331 }
1332 reject_symlinks_in_owned_tree(output_root)
1333}
1334
1335fn ensure_owned_directory(path: &Utf8Path, label: &str) -> Result<(), AndroidError> {
1336 match fs::symlink_metadata(path) {
1337 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
1338 Err(AndroidError::InvalidRequest(format!(
1339 "{label} must be a real directory, not a symlink: `{path}`"
1340 )))
1341 }
1342 Ok(_) => Ok(()),
1343 Err(source) if source.kind() == std::io::ErrorKind::NotFound => fs::create_dir(path)
1344 .map_err(|source| io_error("create owned Android output directory", path, source)),
1345 Err(source) => Err(io_error("inspect Android output directory", path, source)),
1346 }
1347}
1348
1349fn reject_symlinks_in_owned_tree(directory: &Utf8Path) -> Result<(), AndroidError> {
1350 for entry in fs::read_dir(directory)
1351 .map_err(|source| io_error("inspect Android output tree", directory, source))?
1352 {
1353 let entry =
1354 entry.map_err(|source| io_error("inspect Android output entry", directory, source))?;
1355 let path = Utf8PathBuf::from_path_buf(entry.path()).map_err(AndroidError::NonUtf8Path)?;
1356 let metadata = fs::symlink_metadata(&path)
1357 .map_err(|source| io_error("inspect Android output entry", &path, source))?;
1358 if metadata.file_type().is_symlink() {
1359 return Err(AndroidError::InvalidRequest(format!(
1360 "refusing Android output tree containing symlink `{path}`"
1361 )));
1362 }
1363 if metadata.is_dir() {
1364 reject_symlinks_in_owned_tree(&path)?;
1365 } else if !metadata.is_file() {
1366 return Err(AndroidError::InvalidRequest(format!(
1367 "refusing unsupported file type in Android output tree `{path}`"
1368 )));
1369 }
1370 }
1371 Ok(())
1372}
1373
1374fn deduplicate_files_by_content(
1375 mut files: Vec<Utf8PathBuf>,
1376) -> Result<Vec<Utf8PathBuf>, AndroidError> {
1377 files.sort();
1378 files.dedup();
1379 let mut hashes = BTreeSet::new();
1380 let mut unique = Vec::new();
1381 for path in files {
1382 let hash = file_fingerprint(&path)?;
1383 if hashes.insert(hash) {
1384 unique.push(path);
1385 }
1386 }
1387 Ok(unique)
1388}
1389
1390fn file_set_fingerprint(files: &[Utf8PathBuf]) -> Result<String, AndroidError> {
1391 let mut hasher = Sha256::new();
1392 for file in files {
1393 hasher.update(file_fingerprint(file)?);
1394 }
1395 Ok(hex::encode(&hasher.finalize()[..12]))
1396}
1397
1398fn file_fingerprint(path: &Utf8Path) -> Result<[u8; 32], AndroidError> {
1399 let mut file = fs::File::open(path)
1400 .map_err(|source| io_error("open DEX input for fingerprinting", path, source))?;
1401 let mut hasher = Sha256::new();
1402 let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
1403 loop {
1404 let read = file
1405 .read(&mut buffer)
1406 .map_err(|source| io_error("read DEX input for fingerprinting", path, source))?;
1407 if read == 0 {
1408 break;
1409 }
1410 hasher.update(&buffer[..read]);
1411 }
1412 Ok(hasher.finalize().into())
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use std::fs;
1418
1419 use tempfile::TempDir;
1420
1421 use super::*;
1422 use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
1423
1424 mod png_fixture {
1425 include!(concat!(
1426 env!("CARGO_MANIFEST_DIR"),
1427 "/tests/fixtures/opaque_png.rs"
1428 ));
1429 }
1430
1431 use png_fixture::OPAQUE_1024_PNG as PNG;
1432
1433 fn fixture_toolchain(root: &Utf8Path) -> AndroidToolchain {
1434 let tool = |name: &str| {
1435 let path = root.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
1436 fs::write(&path, b"").unwrap();
1437 path
1438 };
1439 let prebuilt = root.join("ndk/toolchains/llvm/prebuilt/test");
1440 fs::create_dir_all(prebuilt.join("bin")).unwrap();
1441 let clang_suffix = if cfg!(target_os = "windows") {
1442 ".cmd"
1443 } else {
1444 ""
1445 };
1446 fs::write(
1447 prebuilt
1448 .join("bin")
1449 .join(format!("aarch64-linux-android26-clang{clang_suffix}")),
1450 b"",
1451 )
1452 .unwrap();
1453 fs::write(
1454 prebuilt
1455 .join("bin")
1456 .join(format!("llvm-ar{}", std::env::consts::EXE_SUFFIX)),
1457 b"",
1458 )
1459 .unwrap();
1460 let android_jar = root.join("android.jar");
1461 fs::write(&android_jar, b"").unwrap();
1462 AndroidToolchain {
1463 sdk_root: root.to_owned(),
1464 platform: AndroidPlatform {
1465 sdk_root: root.to_owned(),
1466 api_level: 35,
1467 directory: root.join("platform"),
1468 android_jar,
1469 },
1470 build_tools: AndroidBuildTools {
1471 sdk_root: root.to_owned(),
1472 version: "35.0.0".to_owned(),
1473 directory: root.join("build-tools"),
1474 aapt2: Some(tool("aapt2")),
1475 d8: Some(tool("d8")),
1476 zipalign: Some(tool("zipalign")),
1477 apksigner: Some(tool("apksigner")),
1478 },
1479 ndk: AndroidNdk {
1480 root: root.join("ndk"),
1481 version: "27.0.0".to_owned(),
1482 llvm_prebuilt: Some(prebuilt),
1483 },
1484 cargo: tool("cargo"),
1485 rustc: Some(tool("rustc")),
1486 rustup: Some(tool("rustup")),
1487 java: Some(tool("java")),
1488 javac: Some(tool("javac")),
1489 keytool: tool("keytool"),
1490 }
1491 }
1492
1493 fn write_project_assets(root: &Utf8Path) {
1494 fs::create_dir_all(root.join("assets")).unwrap();
1495 fs::write(root.join("assets/icon.png"), PNG).unwrap();
1496 fs::write(root.join("assets/splash.png"), PNG).unwrap();
1497 }
1498
1499 fn dex_with_class(descriptor: &str) -> Vec<u8> {
1500 const STRING_IDS: usize = 112;
1501 const TYPE_IDS: usize = 116;
1502 const CLASS_DEFS: usize = 120;
1503 const STRING_DATA: usize = 152;
1504 assert!(descriptor.len() < 128);
1505 let mut dex = vec![0_u8; STRING_DATA + 1 + descriptor.len() + 1];
1506 dex[..8].copy_from_slice(b"dex\n035\0");
1507 for (offset, value) in [
1508 (56, 1_u32),
1509 (60, u32::try_from(STRING_IDS).unwrap()),
1510 (64, 1),
1511 (68, u32::try_from(TYPE_IDS).unwrap()),
1512 (96, 1),
1513 (100, u32::try_from(CLASS_DEFS).unwrap()),
1514 (STRING_IDS, u32::try_from(STRING_DATA).unwrap()),
1515 ] {
1516 dex[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
1517 }
1518 dex[STRING_DATA] = u8::try_from(descriptor.len()).unwrap();
1519 dex[STRING_DATA + 1..STRING_DATA + 1 + descriptor.len()]
1520 .copy_from_slice(descriptor.as_bytes());
1521 dex
1522 }
1523
1524 #[cfg(windows)]
1525 #[test]
1526 fn java_tool_path_normalizes_verbatim_drive_unc_and_unicode_paths() {
1527 assert_eq!(
1528 external_tool_path_arg(Utf8Path::new(r"\\?\C:\work\generated\App.java")).unwrap(),
1529 r"C:\work\generated\App.java"
1530 );
1531 assert_eq!(
1532 external_tool_path_arg(Utf8Path::new(r"\\?\C:\work\classes")).unwrap(),
1533 r"C:\work\classes"
1534 );
1535 assert_eq!(
1536 external_tool_path_arg(Utf8Path::new(r"\\?\UNC\server\share\work\App.java")).unwrap(),
1537 r"\\server\share\work\App.java"
1538 );
1539 assert_eq!(
1540 external_tool_path_arg(Utf8Path::new(r"C:\work\обычный\App.java")).unwrap(),
1541 r"C:\work\обычный\App.java"
1542 );
1543 assert_eq!(
1544 external_tool_path_arg(Utf8Path::new(r"\\?\C:\работа\App.java")).unwrap(),
1545 r"C:\работа\App.java"
1546 );
1547 }
1548
1549 #[test]
1550 fn java_tool_path_rejects_relative_and_control_character_paths() {
1551 for path in ["relative/App.java", "C:/work/App.java\nother"] {
1552 assert!(matches!(
1553 external_tool_path_arg(Utf8Path::new(path)),
1554 Err(AndroidError::InvalidExternalToolPath { .. })
1555 ));
1556 }
1557 }
1558
1559 #[cfg(windows)]
1560 #[test]
1561 fn java_tool_path_rejects_device_and_unsupported_verbatim_namespaces() {
1562 for path in [
1563 r"\\.\C:\work\App.java",
1564 r"\\?\GLOBALROOT\Device\HarddiskVolume1\App.java",
1565 r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\App.java",
1566 ] {
1567 assert!(matches!(
1568 external_tool_path_arg(Utf8Path::new(path)),
1569 Err(AndroidError::InvalidExternalToolPath { .. })
1570 ));
1571 }
1572 }
1573
1574 #[cfg(not(windows))]
1575 #[test]
1576 fn java_tool_path_preserves_absolute_non_windows_paths() {
1577 assert_eq!(
1578 external_tool_path_arg(Utf8Path::new("/tmp/работа/App.java")).unwrap(),
1579 "/tmp/работа/App.java"
1580 );
1581 }
1582
1583 #[cfg(windows)]
1584 #[test]
1585 fn java_tool_path_normalizes_javac_command_plan() {
1586 let temp = TempDir::new().unwrap();
1587 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1588 let mut toolchain = fixture_toolchain(&root);
1589 toolchain.platform.android_jar = r"\\?\C:\Android\platforms\android-36\android.jar".into();
1590 let generated = GeneratedAndroidFiles {
1591 root: r"\\?\C:\work\generated".into(),
1592 manifest: r"\\?\C:\work\generated\AndroidManifest.xml".into(),
1593 resources: r"\\?\C:\work\generated\res".into(),
1594 java_sources: vec![r"\\?\C:\work\generated\FerryActivity.java".into()],
1595 };
1596 let command = javac_command(
1597 &toolchain,
1598 &generated,
1599 Utf8Path::new(r"\\?\C:\work\classes"),
1600 &root,
1601 crate::DEFAULT_COMMAND_TIMEOUT,
1602 )
1603 .unwrap();
1604
1605 assert!(
1606 command
1607 .args
1608 .iter()
1609 .all(|argument| !argument.contains(r"\\?\"))
1610 );
1611 assert!(
1612 command
1613 .args
1614 .contains(&r"C:\Android\platforms\android-36\android.jar".to_owned())
1615 );
1616 assert!(command.args.contains(&r"C:\work\classes".to_owned()));
1617 assert!(
1618 command
1619 .args
1620 .contains(&r"C:\work\generated\FerryActivity.java".to_owned())
1621 );
1622 }
1623
1624 #[cfg(windows)]
1625 #[test]
1626 fn java_tool_path_normalizes_d8_command_plan() {
1627 let temp = TempDir::new().unwrap();
1628 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1629 let mut toolchain = fixture_toolchain(&root);
1630 toolchain.platform.android_jar = r"\\?\C:\Android\platforms\android-36\android.jar".into();
1631 let request = AndroidBuildRequest::new(
1632 &root,
1633 FerryConfig::starter("App", "com.example.app"),
1634 "app",
1635 "app",
1636 );
1637 let command = d8_command(
1638 &toolchain,
1639 &request,
1640 &[
1641 r"\\?\C:\work\classes".into(),
1642 r"\\?\C:\work\dependency.jar".into(),
1643 ],
1644 Utf8Path::new(r"\\?\C:\work\dex"),
1645 )
1646 .unwrap();
1647
1648 assert!(
1649 command
1650 .args
1651 .iter()
1652 .all(|argument| !argument.contains(r"\\?\"))
1653 );
1654 for expected in [
1655 r"C:\Android\platforms\android-36\android.jar",
1656 r"C:\work\classes",
1657 r"C:\work\dependency.jar",
1658 r"C:\work\dex",
1659 ] {
1660 assert!(command.args.contains(&expected.to_owned()));
1661 }
1662 }
1663
1664 #[test]
1665 fn dry_run_plan_has_no_side_effects_and_uses_argument_arrays() {
1666 let temp = TempDir::new().unwrap();
1667 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1668 let project = root.join("Проект с пробелом");
1669 fs::create_dir_all(&project).unwrap();
1670 fs::write(
1671 project.join("Cargo.toml"),
1672 "[package]\nname='weather'\nversion='0.1.0'\n",
1673 )
1674 .unwrap();
1675 write_project_assets(&project);
1676 let config = FerryConfig::starter("Weather", "com.example.weather");
1677 let mut request = AndroidBuildRequest::new(&project, config, "weather", "weather");
1678 request.cargo_target_dir = root.join("target output");
1679 request.discovery = DiscoveryOptions {
1680 sdk_root: None,
1681 ndk_root: None,
1682 java_home: None,
1683 executable_search_paths: vec![],
1684 home_dir: None,
1685 host_tag: None,
1686 };
1687 let plan = plan_android_build(&request, &fixture_toolchain(&root)).unwrap();
1688 assert!(!request.cargo_target_dir.exists());
1689 let cargo = plan
1690 .steps
1691 .iter()
1692 .find(|step| step.stage.starts_with("build Rust"))
1693 .unwrap();
1694 let argv = cargo.command.as_ref().unwrap();
1695 assert!(
1696 argv.iter()
1697 .any(|arg| arg == project.join("Cargo.toml").as_str())
1698 );
1699 let align = plan
1700 .steps
1701 .iter()
1702 .find(|step| step.stage == "align APK")
1703 .unwrap();
1704 assert!(
1705 align
1706 .command
1707 .as_ref()
1708 .unwrap()
1709 .windows(2)
1710 .any(|args| args == ["-P", "16"])
1711 );
1712 let resource_compile = plan
1713 .steps
1714 .iter()
1715 .find(|step| step.stage == "compile Android resources")
1716 .unwrap();
1717 assert!(
1718 resource_compile
1719 .command
1720 .as_ref()
1721 .unwrap()
1722 .contains(&"--no-crunch".to_owned())
1723 );
1724 assert!(
1725 plan.steps
1726 .iter()
1727 .any(|step| step.detail.contains("MissingDex"))
1728 );
1729 }
1730
1731 #[test]
1732 fn duplicate_dependency_dex_is_merged_once_by_content() {
1733 let temp = TempDir::new().unwrap();
1734 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1735 let first = root.join("one.dex");
1736 let second = root.join("two.dex");
1737 fs::write(&first, b"same").unwrap();
1738 fs::write(&second, b"same").unwrap();
1739 assert_eq!(
1740 deduplicate_files_by_content(vec![second, first.clone()]).unwrap(),
1741 vec![first]
1742 );
1743 }
1744
1745 #[test]
1746 fn build_cache_key_changes_with_config_and_toolchain_versions() {
1747 let temp = TempDir::new().unwrap();
1748 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1749 let project = root.join("project");
1750 fs::create_dir_all(&project).unwrap();
1751 fs::write(
1752 project.join("Cargo.toml"),
1753 "[package]\nname='weather'\nversion='0.1.0'\n",
1754 )
1755 .unwrap();
1756 write_project_assets(&project);
1757 let request = AndroidBuildRequest::new(
1758 &project,
1759 FerryConfig::starter("Weather", "com.example.weather"),
1760 "weather",
1761 "weather",
1762 );
1763 let toolchain = fixture_toolchain(&root);
1764 let original = plan_android_build(&request, &toolchain).unwrap();
1765
1766 let mut changed_config = request.clone();
1767 changed_config.config.app.name = "Forecast".to_owned();
1768 let config_plan = plan_android_build(&changed_config, &toolchain).unwrap();
1769 assert_ne!(
1770 original.paths.intermediates,
1771 config_plan.paths.intermediates
1772 );
1773
1774 let mut changed_toolchain = toolchain.clone();
1775 changed_toolchain.build_tools.version = "36.0.0".to_owned();
1776 let toolchain_plan = plan_android_build(&request, &changed_toolchain).unwrap();
1777 assert_ne!(
1778 original.paths.intermediates,
1779 toolchain_plan.paths.intermediates
1780 );
1781
1782 let icon = fs::read(project.join("assets/icon.png")).unwrap();
1783 fs::write(project.join("assets/icon.png"), png_with_text_chunk(&icon)).unwrap();
1784 let asset_plan = plan_android_build(&request, &toolchain).unwrap();
1785 assert_eq!(original.paths.intermediates, asset_plan.paths.intermediates);
1786 }
1787
1788 fn png_with_text_chunk(png: &[u8]) -> Vec<u8> {
1789 const TEXT_CHUNK: &[u8] = &[
1790 0, 0, 0, 3, b't', b'E', b'X', b't', b'x', 0, b'y', 0x45, 0xdb, 0xf3, 0x28,
1791 ];
1792 let iend_offset = png.len().checked_sub(12).expect("PNG IEND chunk");
1793 let mut changed = png[..iend_offset].to_vec();
1794 changed.extend_from_slice(TEXT_CHUNK);
1795 changed.extend_from_slice(&png[iend_offset..]);
1796 changed
1797 }
1798
1799 #[test]
1800 #[ignore = "developer performance measurement; no product threshold"]
1801 fn measures_cache_calculation_and_incremental_no_change_planning() {
1802 use std::time::Instant;
1803
1804 const ITERATIONS: usize = 1_000;
1805 let temporary = TempDir::new().unwrap();
1806 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
1807 let project = root.join("project");
1808 fs::create_dir_all(project.join("src")).unwrap();
1809 fs::write(
1810 project.join("Cargo.toml"),
1811 "[package]\nname='measure'\nversion='0.1.0'\n",
1812 )
1813 .unwrap();
1814 fs::write(project.join("src/lib.rs"), "pub fn value() -> u8 { 1 }\n").unwrap();
1815 write_project_assets(&project);
1816 let inputs = (0..16)
1817 .map(|index| {
1818 let path = project.join(format!("src/input-{index}.bin"));
1819 fs::write(&path, vec![u8::try_from(index).unwrap(); 1_024]).unwrap();
1820 path
1821 })
1822 .collect::<Vec<_>>();
1823 let request = AndroidBuildRequest::new(
1824 &project,
1825 FerryConfig::starter("Measure", "com.example.measure"),
1826 "measure",
1827 "measure",
1828 );
1829 let toolchain = fixture_toolchain(&root);
1830 let expected_digest = file_set_fingerprint(&inputs).unwrap();
1831 let cache_started = Instant::now();
1832 for _ in 0..ITERATIONS {
1833 assert_eq!(file_set_fingerprint(&inputs).unwrap(), expected_digest);
1834 }
1835 let cache_elapsed = cache_started.elapsed();
1836
1837 let expected_plan = plan_android_build(&request, &toolchain).unwrap();
1838 let planning_started = Instant::now();
1839 for _ in 0..ITERATIONS {
1840 let plan = plan_android_build(&request, &toolchain).unwrap();
1841 assert_eq!(plan.paths.intermediates, expected_plan.paths.intermediates);
1842 assert_eq!(
1843 plan.generated_content.fingerprint,
1844 expected_plan.generated_content.fingerprint
1845 );
1846 }
1847 let planning_elapsed = planning_started.elapsed();
1848 eprintln!(
1849 "{ITERATIONS} cache calculations over {} files: {cache_elapsed:?}",
1850 inputs.len()
1851 );
1852 eprintln!("{ITERATIONS} incremental no-change Android plans: {planning_elapsed:?}");
1853 }
1854
1855 #[test]
1856 fn cache_markers_reject_partial_or_modified_outputs() {
1857 let temp = TempDir::new().unwrap();
1858 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1859 let output = root.join("linked.apk");
1860 let marker = root.join("aapt2-link.sha256");
1861 fs::write(&output, b"complete").unwrap();
1862 assert!(!cached_file_matches(&output, &marker));
1863 write_file_cache_marker(&output, &marker).unwrap();
1864 assert!(cached_file_matches(&output, &marker));
1865 fs::write(&output, b"modified").unwrap();
1866 assert!(!cached_file_matches(&output, &marker));
1867 }
1868
1869 #[test]
1870 fn incomplete_toolchain_returns_typed_error() {
1871 let temp = TempDir::new().unwrap();
1872 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1873 let project = root.join("project");
1874 fs::create_dir_all(&project).unwrap();
1875 fs::write(
1876 project.join("Cargo.toml"),
1877 "[package]\nname='weather'\nversion='0.1.0'\n",
1878 )
1879 .unwrap();
1880 write_project_assets(&project);
1881 let request = AndroidBuildRequest::new(
1882 &project,
1883 FerryConfig::starter("Weather", "com.example.weather"),
1884 "weather",
1885 "weather",
1886 );
1887 let mut toolchain = fixture_toolchain(&root);
1888 toolchain.build_tools.aapt2 = None;
1889 assert!(matches!(
1890 plan_android_build(&request, &toolchain),
1891 Err(AndroidError::ToolMissing { tool, .. }) if tool == "aapt2"
1892 ));
1893 }
1894
1895 #[test]
1896 fn widget_manifest_requires_matching_bridge_class() {
1897 let temp = TempDir::new().unwrap();
1898 let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
1899 let mut config = FerryConfig::starter("Widget", "com.example.widget");
1900 config.extensions.widget.enabled = true;
1901 config.extensions.widget.app_group = Some("group.com.example.widget".to_owned());
1902 let request = AndroidBuildRequest::new(&root, config, "widget", "widget");
1903 let descriptors = [
1904 "Lorg/rustferry/bridge/FerryActivity;",
1905 "Lorg/rustferry/bridge/FerryBridge;",
1906 "Lorg/rustferry/bridge/FerryNotificationReceiver;",
1907 "Lorg/rustferry/bridge/FerryWidgetProvider;",
1908 ];
1909 let dex_files = descriptors
1910 .iter()
1911 .enumerate()
1912 .map(|(index, descriptor)| {
1913 let dex = root.join(format!("classes{index}.dex"));
1914 fs::write(&dex, dex_with_class(descriptor)).unwrap();
1915 dex
1916 })
1917 .collect::<Vec<_>>();
1918 validate_required_dex_components(&request, &dex_files).unwrap();
1919
1920 fs::write(
1921 dex_files.last().unwrap(),
1922 dex_with_class("Lorg/rustferry/bridge/Other;"),
1923 )
1924 .unwrap();
1925 assert!(matches!(
1926 validate_required_dex_components(&request, &dex_files),
1927 Err(AndroidError::MissingDexClass { .. })
1928 ));
1929 }
1930
1931 #[test]
1932 fn version_code_is_checked() {
1933 let mut request = AndroidBuildRequest::new(
1934 "/project",
1935 FerryConfig::starter("App", "com.example.app"),
1936 "app",
1937 "app",
1938 );
1939 assert_eq!(version_code(&request).unwrap(), 1_000);
1940 request.config.app.version.major = u64::MAX;
1941 assert!(version_code(&request).is_err());
1942 }
1943
1944 #[cfg(unix)]
1945 #[test]
1946 fn output_preparation_rejects_symlinked_ferry_root() {
1947 use std::os::unix::fs::symlink;
1948
1949 let temporary = TempDir::new().unwrap();
1950 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
1951 let target = root.join("target");
1952 let victim = root.join("victim");
1953 fs::create_dir_all(&target).unwrap();
1954 fs::create_dir_all(&victim).unwrap();
1955 fs::write(victim.join("sentinel"), "preserve").unwrap();
1956 symlink(&victim, target.join("ferry")).unwrap();
1957
1958 let output = target.join("ferry/android/debug");
1959 assert!(matches!(
1960 prepare_android_output_root(&target, &output),
1961 Err(AndroidError::InvalidRequest(_))
1962 ));
1963 assert_eq!(
1964 fs::read_to_string(victim.join("sentinel")).unwrap(),
1965 "preserve"
1966 );
1967 }
1968
1969 #[cfg(unix)]
1970 #[test]
1971 fn d8_reset_rejects_symlinked_parent_without_deleting_target() {
1972 use std::os::unix::fs::symlink;
1973
1974 let temporary = TempDir::new().unwrap();
1975 let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
1976 let intermediates = root.join("intermediates");
1977 let victim = root.join("victim");
1978 let fingerprint = "0123456789abcdef01234567";
1979 fs::create_dir_all(&intermediates).unwrap();
1980 fs::create_dir_all(victim.join(fingerprint)).unwrap();
1981 fs::write(victim.join(fingerprint).join("sentinel"), "preserve").unwrap();
1982 let dex_root = intermediates.join("dex");
1983 symlink(&victim, &dex_root).unwrap();
1984
1985 assert!(matches!(
1986 reset_d8_cache_directory(&dex_root.join(fingerprint), &dex_root),
1987 Err(AndroidError::InvalidRequest(_))
1988 ));
1989 assert_eq!(
1990 fs::read_to_string(victim.join(fingerprint).join("sentinel")).unwrap(),
1991 "preserve"
1992 );
1993 }
1994}