Skip to main content

fission_command_core/
linux_native.rs

1use crate::{
2    native_cargo::{cargo_target_directory, expand_cargo_target_directory},
3    FissionProject, NativeVariant,
4};
5use anyhow::{bail, Context, Result};
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11
12#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
13pub struct NativeLinuxModuleConfig {
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub cargo_manifest_path: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub cargo_package: Option<String>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub features: Vec<String>,
20    #[serde(default, skip_serializing_if = "is_false")]
21    pub no_default_features: bool,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub products: Vec<NativeLinuxProductConfig>,
24}
25
26fn is_false(value: &bool) -> bool {
27    !*value
28}
29
30impl NativeLinuxModuleConfig {
31    pub fn is_empty(&self) -> bool {
32        self.cargo_manifest_path.is_none()
33            && self.cargo_package.is_none()
34            && self.features.is_empty()
35            && !self.no_default_features
36            && self.products.is_empty()
37    }
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum NativeLinuxProductKind {
43    Runtime,
44    PrivilegedHelper,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48pub struct NativeLinuxProductConfig {
49    pub name: String,
50    pub path: String,
51    pub kind: NativeLinuxProductKind,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub destination: Option<String>,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
57pub struct BuiltLinuxNativeProduct {
58    pub module: String,
59    pub name: String,
60    pub kind: NativeLinuxProductKind,
61    pub source: PathBuf,
62    pub destination: PathBuf,
63}
64
65pub fn build_linux_native_modules(
66    project_dir: &Path,
67    project: &FissionProject,
68    variant: Option<&NativeVariant>,
69    release: bool,
70) -> Result<Vec<BuiltLinuxNativeProduct>> {
71    let project_dir = canonical_project_dir(project_dir)?;
72    let profile = if release { "release" } else { "debug" };
73    let mut products = Vec::new();
74
75    for module in project.native_modules_for_variant(variant) {
76        if module.linux.is_empty() {
77            continue;
78        }
79        let target_directory = run_cargo_module_command(
80            &project_dir,
81            module.path.as_deref(),
82            &module.name,
83            &module.linux,
84            "build",
85            release,
86        )?;
87
88        for product in &module.linux.products {
89            products.push(resolve_product(
90                &project_dir,
91                &module.name,
92                product,
93                profile,
94                env::consts::ARCH,
95                &target_directory,
96            )?);
97        }
98    }
99
100    Ok(products)
101}
102
103pub fn test_linux_native_modules(
104    project_dir: &Path,
105    project: &FissionProject,
106    variant: Option<&NativeVariant>,
107) -> Result<()> {
108    let project_dir = canonical_project_dir(project_dir)?;
109    for module in project.native_modules_for_variant(variant) {
110        if module.linux.is_empty() {
111            continue;
112        }
113        run_cargo_module_command(
114            &project_dir,
115            module.path.as_deref(),
116            &module.name,
117            &module.linux,
118            "test",
119            false,
120        )?;
121    }
122    Ok(())
123}
124
125pub fn stage_linux_native_products(
126    destination_root: &Path,
127    products: &[BuiltLinuxNativeProduct],
128) -> Result<()> {
129    for product in products {
130        let destination = destination_root.join(&product.destination);
131        if destination.exists() {
132            bail!(
133                "Linux native product `{}` would overwrite {}",
134                product.name,
135                destination.display()
136            );
137        }
138        copy_product(&product.source, &destination)?;
139    }
140    Ok(())
141}
142
143fn run_cargo_module_command(
144    project_dir: &Path,
145    module_path: Option<&str>,
146    module_name: &str,
147    config: &NativeLinuxModuleConfig,
148    cargo_command: &str,
149    release: bool,
150) -> Result<PathBuf> {
151    let package = required_optional_value(
152        config.cargo_package.as_deref(),
153        &format!("Linux native module `{module_name}` requires `cargo_package`"),
154    )?;
155    let manifest = resolve_manifest_path(
156        project_dir,
157        module_path,
158        config.cargo_manifest_path.as_deref(),
159        module_name,
160    )?;
161    let mut command = Command::new("cargo");
162    command
163        .arg(cargo_command)
164        .arg("--manifest-path")
165        .arg(&manifest)
166        .arg("--package")
167        .arg(package)
168        .current_dir(project_dir);
169    if release && cargo_command == "build" {
170        command.arg("--release");
171    }
172    if config.no_default_features {
173        command.arg("--no-default-features");
174    }
175    if !config.features.is_empty() {
176        let features = config
177            .features
178            .iter()
179            .map(|feature| required_value(feature, "Linux native Cargo feature"))
180            .collect::<Result<Vec<_>>>()?
181            .join(",");
182        command.arg("--features").arg(features);
183    }
184    run_status(
185        &mut command,
186        &format!("Linux native module `{module_name}` Cargo {cargo_command}"),
187    )?;
188    cargo_target_directory(project_dir, &manifest, module_name, "Linux")
189}
190
191fn resolve_manifest_path(
192    project_dir: &Path,
193    module_path: Option<&str>,
194    configured: Option<&str>,
195    module_name: &str,
196) -> Result<PathBuf> {
197    let manifest = if let Some(configured) = optional_value(configured) {
198        resolve_project_path(project_dir, configured)
199    } else if let Some(module_path) = optional_value(module_path) {
200        resolve_project_path(project_dir, module_path).join("Cargo.toml")
201    } else {
202        project_dir.join("Cargo.toml")
203    };
204    if !manifest.is_file() {
205        bail!(
206            "Linux native module `{module_name}` Cargo manifest does not exist: {}",
207            manifest.display()
208        );
209    }
210    fs::canonicalize(&manifest).with_context(|| {
211        format!(
212            "failed to resolve Linux native module `{module_name}` Cargo manifest {}",
213            manifest.display()
214        )
215    })
216}
217
218fn resolve_product(
219    project_dir: &Path,
220    module_name: &str,
221    product: &NativeLinuxProductConfig,
222    profile: &str,
223    architecture: &str,
224    cargo_target_directory: &Path,
225) -> Result<BuiltLinuxNativeProduct> {
226    let name = required_value(&product.name, "Linux native product name")?;
227    let path = required_value(&product.path, "Linux native product path")?;
228    let expanded = expand_path(
229        path,
230        profile,
231        architecture,
232        cargo_target_directory,
233        module_name,
234    )?;
235    let source = resolve_project_path(project_dir, &expanded);
236    if !source.exists() {
237        bail!(
238            "Linux native product `{name}` from module `{module_name}` does not exist: {}",
239            source.display()
240        );
241    }
242    if product.kind == NativeLinuxProductKind::PrivilegedHelper && !source.is_file() {
243        bail!("Linux privileged helper `{name}` must be a regular file");
244    }
245    let default_destination = source
246        .file_name()
247        .map(PathBuf::from)
248        .context("Linux native product source has no file name")?;
249    let destination = product
250        .destination
251        .as_deref()
252        .map(str::trim)
253        .filter(|value| !value.is_empty())
254        .map(PathBuf::from)
255        .unwrap_or(default_destination);
256    validate_relative_destination(&destination)?;
257
258    Ok(BuiltLinuxNativeProduct {
259        module: module_name.to_string(),
260        name: name.to_string(),
261        kind: product.kind,
262        source,
263        destination,
264    })
265}
266
267fn validate_relative_destination(destination: &Path) -> Result<()> {
268    if destination.as_os_str().is_empty() || destination.is_absolute() {
269        bail!("Linux native product destination must be a non-empty relative path");
270    }
271    if destination.components().any(|component| {
272        matches!(
273            component,
274            Component::ParentDir | Component::RootDir | Component::Prefix(_)
275        )
276    }) {
277        bail!(
278            "Linux native product destination cannot escape the application root: {}",
279            destination.display()
280        );
281    }
282    Ok(())
283}
284
285fn expand_path(
286    value: &str,
287    profile: &str,
288    architecture: &str,
289    cargo_target_directory: &Path,
290    module_name: &str,
291) -> Result<String> {
292    let configuration = if profile == "release" {
293        "Release"
294    } else {
295        "Debug"
296    };
297    let value = value
298        .replace("{profile}", profile)
299        .replace("{configuration}", configuration)
300        .replace("{architecture}", architecture);
301    expand_cargo_target_directory(&value, Some(cargo_target_directory), module_name, "Linux")
302}
303
304fn copy_product(source: &Path, destination: &Path) -> Result<()> {
305    if source.is_dir() {
306        fs::create_dir_all(destination)?;
307        for entry in fs::read_dir(source)? {
308            let entry = entry?;
309            copy_product(&entry.path(), &destination.join(entry.file_name()))?;
310        }
311        return Ok(());
312    }
313    let parent = destination
314        .parent()
315        .context("Linux native product destination has no parent")?;
316    fs::create_dir_all(parent)?;
317    fs::copy(source, destination).with_context(|| {
318        format!(
319            "failed to copy Linux native product {} to {}",
320            source.display(),
321            destination.display()
322        )
323    })?;
324    Ok(())
325}
326
327fn resolve_project_path(project_dir: &Path, value: &str) -> PathBuf {
328    let path = Path::new(value);
329    if path.is_absolute() {
330        path.to_path_buf()
331    } else {
332        project_dir.join(path)
333    }
334}
335
336fn canonical_project_dir(project_dir: &Path) -> Result<PathBuf> {
337    fs::canonicalize(project_dir).with_context(|| {
338        format!(
339            "failed to resolve project directory {}",
340            project_dir.display()
341        )
342    })
343}
344
345fn required_optional_value<'a>(value: Option<&'a str>, message: &str) -> Result<&'a str> {
346    optional_value(value).with_context(|| message.to_string())
347}
348
349fn required_value<'a>(value: &'a str, label: &str) -> Result<&'a str> {
350    let value = value.trim();
351    if value.is_empty() {
352        bail!("{label} cannot be empty");
353    }
354    Ok(value)
355}
356
357fn optional_value(value: Option<&str>) -> Option<&str> {
358    value.map(str::trim).filter(|value| !value.is_empty())
359}
360
361fn run_status(command: &mut Command, label: &str) -> Result<()> {
362    let status = command
363        .status()
364        .with_context(|| format!("failed to run {label}"))?;
365    if !status.success() {
366        bail!("{label} failed with {status}");
367    }
368    Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn parses_linux_native_products() {
377        let project: FissionProject = toml::from_str(
378            r#"
379targets = ["linux"]
380
381[app]
382name = "demo"
383app_id = "com.example.demo"
384
385[[native.modules]]
386name = "demo-native"
387path = "platforms/linux/native"
388
389[native.modules.linux]
390cargo_package = "demo-mount-helper"
391features = ["mount"]
392no_default_features = true
393
394[[native.modules.linux.products]]
395name = "mount-helper"
396path = "target/{profile}/demo-mount-helper"
397kind = "privileged-helper"
398destination = "libexec/demo-mount-helper"
399"#,
400        )
401        .unwrap();
402
403        let module = &project.native.modules[0].linux;
404        assert_eq!(module.cargo_package.as_deref(), Some("demo-mount-helper"));
405        assert_eq!(module.features, ["mount"]);
406        assert!(module.no_default_features);
407        assert_eq!(
408            module.products[0].kind,
409            NativeLinuxProductKind::PrivilegedHelper
410        );
411    }
412
413    #[test]
414    fn expands_profile_configuration_and_architecture_tokens() {
415        assert_eq!(
416            expand_path(
417                "{cargo_target_dir}/{architecture}/{configuration}/{profile}",
418                "release",
419                "x86_64",
420                Path::new("/shared/cargo"),
421                "demo-native",
422            )
423            .unwrap(),
424            "/shared/cargo/x86_64/Release/release"
425        );
426    }
427
428    #[test]
429    fn rejects_destination_traversal() {
430        let error = validate_relative_destination(Path::new("../helper")).unwrap_err();
431        assert!(error.to_string().contains("cannot escape"));
432    }
433
434    #[test]
435    fn stages_runtime_and_privileged_products() {
436        let root = unique_dir("linux-native-stage");
437        let source = root.join("source");
438        let destination = root.join("destination");
439        fs::create_dir_all(&source).unwrap();
440        fs::write(source.join("provider.so"), b"runtime").unwrap();
441        fs::write(source.join("mount-helper"), b"helper").unwrap();
442        let products = vec![
443            BuiltLinuxNativeProduct {
444                module: "demo".into(),
445                name: "provider".into(),
446                kind: NativeLinuxProductKind::Runtime,
447                source: source.join("provider.so"),
448                destination: PathBuf::from("lib/provider.so"),
449            },
450            BuiltLinuxNativeProduct {
451                module: "demo".into(),
452                name: "mount-helper".into(),
453                kind: NativeLinuxProductKind::PrivilegedHelper,
454                source: source.join("mount-helper"),
455                destination: PathBuf::from("libexec/mount-helper"),
456            },
457        ];
458
459        stage_linux_native_products(&destination, &products).unwrap();
460
461        assert_eq!(
462            fs::read(destination.join("lib/provider.so")).unwrap(),
463            b"runtime"
464        );
465        assert_eq!(
466            fs::read(destination.join("libexec/mount-helper")).unwrap(),
467            b"helper"
468        );
469    }
470
471    fn unique_dir(label: &str) -> PathBuf {
472        let path = std::env::temp_dir().join(format!(
473            "fission-{label}-{}-{}",
474            std::process::id(),
475            std::time::SystemTime::now()
476                .duration_since(std::time::UNIX_EPOCH)
477                .unwrap()
478                .as_nanos()
479        ));
480        fs::create_dir_all(&path).unwrap();
481        path
482    }
483}