Skip to main content

rustferry_android/
build.rs

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/// Cargo profile used for the native build and manifest debuggability.
29#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum AndroidBuildProfile {
32    /// Incremental debug build.
33    #[default]
34    Debug,
35    /// Optimized Cargo release build.
36    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/// Whether the application intentionally contains JVM DEX code.
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum DexPolicy {
52    /// No DEX is packaged and the manifest declares `hasCode=false`.
53    None,
54    /// At least one dependency or explicit bridge DEX must be found and merged with D8.
55    #[default]
56    Required,
57}
58
59/// Complete direct Android build request.
60#[derive(Clone, Debug, PartialEq)]
61pub struct AndroidBuildRequest {
62    /// User project root containing `Cargo.toml`.
63    pub project_dir: Utf8PathBuf,
64    /// Cargo target directory; generated output stays below `<target>/ferry/`.
65    pub cargo_target_dir: Utf8PathBuf,
66    /// Strict validated `RustFerry` configuration.
67    pub config: FerryConfig,
68    /// Cargo package selected with `--package`.
69    pub cargo_package_name: String,
70    /// Cargo library target name as reported in JSON messages.
71    pub cargo_library_target: String,
72    /// Android native library name without `lib` or `.so`.
73    pub native_library_name: String,
74    /// Debug or release Cargo profile.
75    pub profile: AndroidBuildProfile,
76    /// DEX contract. Slint starters use [`DexPolicy::Required`].
77    pub dex_policy: DexPolicy,
78    /// Extra generated/prebuilt bridge `.dex`, `.jar`, `.class`, or directories.
79    pub bridge_dex_inputs: Vec<Utf8PathBuf>,
80    /// Debug or user-provided signing identity.
81    pub signing: AndroidSigningConfig,
82    /// Read-only SDK/NDK discovery overrides.
83    pub discovery: DiscoveryOptions,
84    /// Return an exact plan without creating files or running commands.
85    pub dry_run: bool,
86    /// Deadline applied to each external tool.
87    pub command_timeout: Duration,
88}
89
90impl AndroidBuildRequest {
91    /// Create a starter request with a persistent debug signer and required Slint DEX.
92    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/// Stable kind for a dry-run build operation.
119#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum AndroidPlanStepKind {
122    /// Deterministic file generation.
123    Generate,
124    /// External executable invocation.
125    Command,
126    /// Cargo JSON artifact discovery.
127    Collect,
128    /// ZIP entry injection.
129    Package,
130    /// Independent artifact/tool verification.
131    Verify,
132    /// Machine-local debug signing preparation.
133    Signing,
134}
135
136/// One human/JSON-friendly dry-run step.
137#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
138pub struct AndroidPlanStep {
139    /// Stable stage label.
140    pub stage: String,
141    /// Operation kind.
142    pub kind: AndroidPlanStepKind,
143    /// Explanation or deferred input contract.
144    pub detail: String,
145    /// Redacted executable and argument array, when applicable.
146    pub command: Option<Vec<String>>,
147    /// Expected output paths.
148    pub outputs: Vec<Utf8PathBuf>,
149}
150
151/// All deterministic output paths for one build fingerprint.
152#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
153pub struct AndroidBuildPaths {
154    /// `target/ferry/android/<profile>`.
155    pub root: Utf8PathBuf,
156    /// Fingerprinted generated source root parent.
157    pub generated_root: Utf8PathBuf,
158    /// Fingerprinted intermediates.
159    pub intermediates: Utf8PathBuf,
160    /// Compiled internal Java bridge classes.
161    pub bridge_classes: Utf8PathBuf,
162    /// Command logs.
163    pub logs: Utf8PathBuf,
164    /// Compiled AAPT2 resource archive.
165    pub compiled_resources: Utf8PathBuf,
166    /// Resource-linked unsigned base APK.
167    pub linked_apk: Utf8PathBuf,
168    /// APK after native/DEX injection.
169    pub assembled_apk: Utf8PathBuf,
170    /// APK after 16 KiB/4-byte alignment.
171    pub aligned_apk: Utf8PathBuf,
172    /// Final signed artifact.
173    pub final_apk: Utf8PathBuf,
174}
175
176/// Executable direct Android plan.
177#[derive(Clone, Debug, PartialEq)]
178pub struct AndroidBuildPlan {
179    /// Selected SDK/NDK/build tools.
180    pub toolchain: AndroidToolchain,
181    /// Deterministic manifest/resources.
182    pub generated_content: GeneratedAndroidContent,
183    /// Build output paths.
184    pub paths: AndroidBuildPaths,
185    /// Cargo commands, one per configured ABI.
186    pub cargo_commands: Vec<(AndroidAbi, CommandSpec)>,
187    /// Ordered redacted plan steps.
188    pub steps: Vec<AndroidPlanStep>,
189}
190
191/// Verified signed APK metadata.
192#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
193pub struct AndroidBuildArtifact {
194    /// Final APK path.
195    apk: Utf8PathBuf,
196    /// Independent archive/package/native validation evidence.
197    validation: ApkValidation,
198    /// Reused deterministic stages.
199    cache_hits: Vec<String>,
200    /// Full command log directory.
201    log_dir: Utf8PathBuf,
202}
203
204impl AndroidBuildArtifact {
205    /// Final APK path bound to the completed build validation.
206    #[must_use]
207    pub fn apk(&self) -> &Utf8Path {
208        &self.apk
209    }
210
211    /// Independent archive, package, manifest, native-code, and digest evidence.
212    #[must_use]
213    pub const fn validation(&self) -> &ApkValidation {
214        &self.validation
215    }
216
217    /// Deterministic stages reused by this build.
218    #[must_use]
219    pub fn cache_hits(&self) -> &[String] {
220        &self.cache_hits
221    }
222
223    /// Full command log directory.
224    #[must_use]
225    pub fn log_dir(&self) -> &Utf8Path {
226        &self.log_dir
227    }
228}
229
230/// Result of build orchestration.
231#[derive(Clone, Debug, PartialEq)]
232#[allow(clippy::large_enum_variant)] // Keep the public Built payload source-compatible.
233pub enum AndroidBuildOutcome {
234    /// No mutation or external process occurred.
235    DryRun(Box<AndroidBuildPlan>),
236    /// Signed and independently verified APK.
237    Built(AndroidBuildArtifact),
238}
239
240/// Discover tools, create a plan, and either return it or execute the complete pipeline.
241///
242/// # Errors
243///
244/// Returns typed discovery, generation, build-tool, signing, or validation failures.
245pub 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
255/// Build a side-effect-free direct Android execution plan from a selected toolchain.
256///
257/// # Errors
258///
259/// Returns an error for invalid request values or incomplete selected SDK/NDK tools.
260pub 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(&current, "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}