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}