Skip to main content

rustferry_android/
cargo_build.rs

1use std::fs;
2
3use camino::{Utf8Path, Utf8PathBuf};
4use rustferry_core::AndroidAbi;
5use serde::Deserialize;
6
7use crate::command::external_tool_path_arg;
8use crate::{AndroidError, AndroidToolchain, CommandSpec};
9
10/// Native and JVM artifacts extracted from one Cargo JSON message stream.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct CargoBuildArtifacts {
13    /// Matching Android `cdylib`.
14    pub native_library: Utf8PathBuf,
15    /// DEX files recursively found in dependency build-script output directories.
16    pub dependency_dex_files: Vec<Utf8PathBuf>,
17    /// Every valid build-script output directory searched.
18    pub build_script_out_dirs: Vec<Utf8PathBuf>,
19}
20
21/// Create a Cargo cross-build command using explicit target/linker arguments.
22///
23/// # Errors
24///
25/// Returns an error when the selected NDK lacks the ABI linker or LLVM archiver.
26pub fn cargo_build_command(
27    toolchain: &AndroidToolchain,
28    project_dir: &Utf8Path,
29    cargo_target_dir: &Utf8Path,
30    package_name: &str,
31    abi: AndroidAbi,
32    min_sdk: u32,
33    release: bool,
34) -> Result<CommandSpec, AndroidError> {
35    let linker = toolchain.linker_for(abi, min_sdk)?;
36    let archiver = toolchain.llvm_ar()?;
37    let target = abi.rust_target();
38    let mut command = CommandSpec::new(
39        format!("build Rust cdylib for {}", abi.apk_directory()),
40        toolchain.cargo.clone(),
41        Utf8PathBuf::from(external_tool_path_arg(project_dir)?),
42    );
43    command.args = vec![
44        "build".to_owned(),
45        "--manifest-path".to_owned(),
46        external_tool_path_arg(&project_dir.join("Cargo.toml"))?,
47        "--package".to_owned(),
48        package_name.to_owned(),
49        "--lib".to_owned(),
50        "--target".to_owned(),
51        target.to_owned(),
52        "--target-dir".to_owned(),
53        external_tool_path_arg(cargo_target_dir)?,
54        "--message-format=json-render-diagnostics".to_owned(),
55    ];
56    if release {
57        command.args.push("--release".to_owned());
58    }
59    let cargo_target_key = target.replace('-', "_").to_ascii_uppercase();
60    let cc_target_key = target.replace('-', "_");
61    command.environment.insert(
62        format!("CARGO_TARGET_{cargo_target_key}_LINKER"),
63        external_tool_path_arg(&linker)?,
64    );
65    command.environment.insert(
66        format!("CC_{cc_target_key}"),
67        external_tool_path_arg(&linker)?,
68    );
69    command.environment.insert(
70        format!("AR_{cc_target_key}"),
71        external_tool_path_arg(&archiver)?,
72    );
73    set_android_environment(&mut command, &toolchain.sdk_root, &toolchain.ndk.root)?;
74    Ok(command)
75}
76
77fn set_android_environment(
78    command: &mut CommandSpec,
79    sdk_root: &Utf8Path,
80    ndk_root: &Utf8Path,
81) -> Result<(), AndroidError> {
82    let sdk_root = external_tool_path_arg(sdk_root)?;
83    let ndk_root = external_tool_path_arg(ndk_root)?;
84    for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
85        command
86            .environment
87            .insert(name.to_owned(), sdk_root.clone());
88    }
89    for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
90        command
91            .environment
92            .insert(name.to_owned(), ndk_root.clone());
93    }
94    Ok(())
95}
96
97/// Parse Cargo JSON, constrain reported paths to Cargo's target directory, and collect DEX files.
98///
99/// # Errors
100///
101/// Returns an error for malformed Cargo JSON, unsafe reported paths, or a missing matching
102/// `cdylib`.
103pub fn collect_cargo_artifacts(
104    stdout: &[u8],
105    rust_target: &str,
106    library_target_name: &str,
107    cargo_target_dir: &Utf8Path,
108) -> Result<CargoBuildArtifacts, AndroidError> {
109    let target_root = canonical_utf8(cargo_target_dir)?;
110    let text = std::str::from_utf8(stdout)
111        .map_err(|error| AndroidError::CargoOutput(format!("stdout was not UTF-8: {error}")))?;
112    let mut native_candidates = Vec::new();
113    let mut out_dirs = Vec::new();
114    for (line_number, line) in text.lines().enumerate() {
115        if line.trim().is_empty() {
116            continue;
117        }
118        let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
119            AndroidError::CargoOutput(format!(
120                "line {} was not a Cargo JSON message: {error}",
121                line_number + 1
122            ))
123        })?;
124        let reason = value.get("reason").and_then(serde_json::Value::as_str);
125        match reason {
126            Some("compiler-artifact") => {
127                let message: CompilerArtifact = serde_json::from_value(value).map_err(|error| {
128                    AndroidError::CargoOutput(format!("malformed compiler-artifact: {error}"))
129                })?;
130                if message.target.name == library_target_name
131                    && (message
132                        .target
133                        .crate_types
134                        .iter()
135                        .any(|kind| kind == "cdylib")
136                        || message.target.kind.iter().any(|kind| kind == "cdylib"))
137                {
138                    native_candidates.extend(
139                        message
140                            .filenames
141                            .into_iter()
142                            .filter(|path| path.extension() == Some("so")),
143                    );
144                }
145            }
146            Some("build-script-executed") => {
147                let message: BuildScriptExecuted =
148                    serde_json::from_value(value).map_err(|error| {
149                        AndroidError::CargoOutput(format!(
150                            "malformed build-script-executed: {error}"
151                        ))
152                    })?;
153                if let Some(path) = safe_reported_directory(&message.out_dir, &target_root) {
154                    out_dirs.push(path);
155                }
156            }
157            _ => {}
158        }
159    }
160    out_dirs.sort();
161    out_dirs.dedup();
162
163    let native_library = native_candidates
164        .iter()
165        .find(|path| {
166            path.file_name().is_some_and(|name| {
167                name == format!("lib{}.so", library_target_name.replace('-', "_"))
168            })
169        })
170        .or_else(|| native_candidates.first())
171        .cloned()
172        .ok_or_else(|| AndroidError::NativeLibraryMissing {
173            target: rust_target.to_owned(),
174            searched: native_candidates.clone(),
175        })?;
176    let native_library = safe_reported_file(&native_library, &target_root).ok_or_else(|| {
177        AndroidError::NativeLibraryMissing {
178            target: rust_target.to_owned(),
179            searched: native_candidates,
180        }
181    })?;
182
183    let mut dex_files = Vec::new();
184    for out_dir in &out_dirs {
185        collect_extension_files(out_dir, "dex", 10, &mut dex_files);
186    }
187    dex_files.sort();
188    dex_files.dedup();
189    Ok(CargoBuildArtifacts {
190        native_library,
191        dependency_dex_files: dex_files,
192        build_script_out_dirs: out_dirs,
193    })
194}
195
196/// Recursively collect explicit bridge DEX/JAR inputs without following symlinks.
197///
198/// # Errors
199///
200/// Returns an error when an input is missing or is not a supported file/directory.
201pub fn collect_explicit_dex_inputs(
202    inputs: &[Utf8PathBuf],
203) -> Result<Vec<Utf8PathBuf>, AndroidError> {
204    let mut files = Vec::new();
205    for input in inputs {
206        if input.is_dir() {
207            collect_dex_and_jar_files(input, 10, &mut files);
208        } else if input.is_file() && matches!(input.extension(), Some("dex" | "jar" | "class")) {
209            files.push(input.clone());
210        } else {
211            return Err(AndroidError::InvalidRequest(format!(
212                "DEX/bridge input is missing or unsupported: {input}"
213            )));
214        }
215    }
216    files.sort();
217    files.dedup();
218    Ok(files)
219}
220
221#[derive(Deserialize)]
222struct CompilerArtifact {
223    target: CargoTarget,
224    filenames: Vec<Utf8PathBuf>,
225}
226
227#[derive(Deserialize)]
228struct CargoTarget {
229    name: String,
230    kind: Vec<String>,
231    crate_types: Vec<String>,
232}
233
234#[derive(Deserialize)]
235struct BuildScriptExecuted {
236    out_dir: Utf8PathBuf,
237}
238
239fn safe_reported_directory(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
240    let canonical = canonical_utf8(path).ok()?;
241    (canonical.starts_with(root) && canonical.is_dir()).then_some(canonical)
242}
243
244fn safe_reported_file(path: &Utf8Path, root: &Utf8Path) -> Option<Utf8PathBuf> {
245    let canonical = canonical_utf8(path).ok()?;
246    (canonical.starts_with(root) && canonical.is_file()).then_some(canonical)
247}
248
249fn canonical_utf8(path: &Utf8Path) -> Result<Utf8PathBuf, AndroidError> {
250    let canonical = fs::canonicalize(path)
251        .map_err(|source| crate::error::io_error("canonicalize build path", path, source))?;
252    Utf8PathBuf::from_path_buf(canonical).map_err(AndroidError::NonUtf8Path)
253}
254
255fn collect_extension_files(
256    root: &Utf8Path,
257    extension: &str,
258    depth: usize,
259    output: &mut Vec<Utf8PathBuf>,
260) {
261    if depth == 0 {
262        return;
263    }
264    let Ok(entries) = fs::read_dir(root) else {
265        return;
266    };
267    let mut paths = entries
268        .filter_map(Result::ok)
269        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
270        .collect::<Vec<_>>();
271    paths.sort();
272    for path in paths {
273        let Ok(metadata) = fs::symlink_metadata(&path) else {
274            continue;
275        };
276        if metadata.file_type().is_symlink() {
277            continue;
278        }
279        if metadata.is_dir() {
280            collect_extension_files(&path, extension, depth - 1, output);
281        } else if path.extension() == Some(extension) {
282            output.push(path);
283        }
284    }
285}
286
287fn collect_dex_and_jar_files(root: &Utf8Path, depth: usize, output: &mut Vec<Utf8PathBuf>) {
288    if depth == 0 {
289        return;
290    }
291    let Ok(entries) = fs::read_dir(root) else {
292        return;
293    };
294    let mut paths = entries
295        .filter_map(Result::ok)
296        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
297        .collect::<Vec<_>>();
298    paths.sort();
299    for path in paths {
300        let Ok(metadata) = fs::symlink_metadata(&path) else {
301            continue;
302        };
303        if metadata.file_type().is_symlink() {
304            continue;
305        }
306        if metadata.is_dir() {
307            collect_dex_and_jar_files(&path, depth - 1, output);
308        } else if matches!(path.extension(), Some("dex" | "jar" | "class")) {
309            output.push(path);
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use std::fs;
317
318    use tempfile::TempDir;
319
320    use super::*;
321    #[cfg(windows)]
322    use crate::{AndroidBuildTools, AndroidNdk, AndroidPlatform};
323
324    #[cfg(windows)]
325    fn fixture_toolchain(root: &Utf8Path) -> AndroidToolchain {
326        let tool = |name: &str| {
327            let path = root.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
328            fs::write(&path, b"").unwrap();
329            path
330        };
331        let prebuilt = root.join("ndk/toolchains/llvm/prebuilt/test");
332        fs::create_dir_all(prebuilt.join("bin")).unwrap();
333        let clang_suffix = if cfg!(windows) { ".cmd" } else { "" };
334        fs::write(
335            prebuilt
336                .join("bin")
337                .join(format!("aarch64-linux-android26-clang{clang_suffix}")),
338            b"",
339        )
340        .unwrap();
341        fs::write(
342            prebuilt
343                .join("bin")
344                .join(format!("llvm-ar{}", std::env::consts::EXE_SUFFIX)),
345            b"",
346        )
347        .unwrap();
348        AndroidToolchain {
349            sdk_root: root.join("sdk"),
350            platform: AndroidPlatform {
351                sdk_root: root.join("sdk"),
352                api_level: 36,
353                directory: root.join("sdk/platforms/android-36"),
354                android_jar: root.join("sdk/platforms/android-36/android.jar"),
355            },
356            build_tools: AndroidBuildTools {
357                sdk_root: root.join("sdk"),
358                version: "36.0.0".to_owned(),
359                directory: root.join("sdk/build-tools/36.0.0"),
360                aapt2: None,
361                d8: None,
362                zipalign: None,
363                apksigner: None,
364            },
365            ndk: AndroidNdk {
366                root: root.join("ndk"),
367                version: "29.0.0".to_owned(),
368                llvm_prebuilt: Some(prebuilt),
369            },
370            cargo: tool("cargo"),
371            rustc: None,
372            rustup: None,
373            java: None,
374            javac: None,
375            keytool: tool("keytool"),
376        }
377    }
378
379    fn cargo_messages(native: &Utf8Path, out_dir: &Utf8Path) -> String {
380        [
381            serde_json::json!({
382                "reason": "compiler-artifact",
383                "target": {
384                    "name": "weather",
385                    "kind": ["cdylib"],
386                    "crate_types": ["cdylib"]
387                },
388                "filenames": [native.as_str()]
389            }),
390            serde_json::json!({
391                "reason": "build-script-executed",
392                "out_dir": out_dir.as_str()
393            }),
394        ]
395        .into_iter()
396        .map(|message| message.to_string())
397        .collect::<Vec<_>>()
398        .join("\n")
399            + "\n"
400    }
401
402    #[test]
403    fn android_environment_includes_native_dependency_ndk_aliases() {
404        let mut command = CommandSpec::new("test", "cargo", ".");
405        #[cfg(windows)]
406        let (sdk, ndk) = (r"C:\Android\sdk", r"C:\Android\ndk");
407        #[cfg(not(windows))]
408        let (sdk, ndk) = ("/android/sdk", "/android/ndk");
409        set_android_environment(&mut command, Utf8Path::new(sdk), Utf8Path::new(ndk)).unwrap();
410
411        for name in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] {
412            assert_eq!(command.environment.get(name).map(String::as_str), Some(sdk));
413        }
414        for name in ["ANDROID_NDK", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"] {
415            assert_eq!(command.environment.get(name).map(String::as_str), Some(ndk));
416        }
417    }
418
419    #[cfg(windows)]
420    #[test]
421    fn cargo_build_command_normalizes_windows_verbatim_project_paths() {
422        let temporary = TempDir::new().unwrap();
423        let root = Utf8PathBuf::from_path_buf(temporary.path().to_owned()).unwrap();
424        let command = cargo_build_command(
425            &fixture_toolchain(&root),
426            Utf8Path::new(r"\\?\C:\work\calculator"),
427            Utf8Path::new(r"\\?\C:\work\calculator\target"),
428            "calculator",
429            AndroidAbi::Arm64V8a,
430            26,
431            false,
432        )
433        .unwrap();
434
435        assert_eq!(command.current_dir, Utf8Path::new(r"C:\work\calculator"));
436        assert!(
437            command
438                .args
439                .iter()
440                .chain(command.environment.values())
441                .all(|value| !value.contains(r"\\?\"))
442        );
443        assert!(
444            command
445                .args
446                .contains(&r"C:\work\calculator\target".to_owned())
447        );
448    }
449
450    #[test]
451    fn extracts_cdylib_and_dependency_dex_from_cargo_json() {
452        let temp = TempDir::new().unwrap();
453        let root = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap();
454        let native = root.join("aarch64-linux-android/debug/libweather.so");
455        let out_dir = root.join("aarch64-linux-android/debug/build/slint/out");
456        let dex = out_dir.join("classes.dex");
457        fs::create_dir_all(native.parent().unwrap()).unwrap();
458        fs::create_dir_all(&out_dir).unwrap();
459        fs::write(&native, b"native").unwrap();
460        fs::write(&dex, b"dex\n035\0").unwrap();
461        let json = cargo_messages(&native, &out_dir);
462        let artifacts =
463            collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &root)
464                .unwrap();
465        assert_eq!(artifacts.native_library, fs::canonicalize(native).unwrap());
466        assert_eq!(
467            artifacts.dependency_dex_files,
468            vec![fs::canonicalize(dex).unwrap()]
469        );
470        assert_eq!(artifacts.build_script_out_dirs.len(), 1);
471    }
472
473    #[test]
474    fn refuses_out_dirs_outside_the_cargo_target() {
475        let target = TempDir::new().unwrap();
476        let outside = TempDir::new().unwrap();
477        let target = Utf8PathBuf::from_path_buf(target.path().to_owned()).unwrap();
478        let outside = Utf8PathBuf::from_path_buf(outside.path().to_owned()).unwrap();
479        let native = target.join("libweather.so");
480        fs::write(&native, b"native").unwrap();
481        fs::write(outside.join("classes.dex"), b"dex\n035\0").unwrap();
482        let json = cargo_messages(&native, &outside);
483        let artifacts =
484            collect_cargo_artifacts(json.as_bytes(), "aarch64-linux-android", "weather", &target)
485                .unwrap();
486        assert!(artifacts.dependency_dex_files.is_empty());
487        assert!(artifacts.build_script_out_dirs.is_empty());
488    }
489}