Skip to main content

fission_command_core/
desktop_features.rs

1use crate::Target;
2use anyhow::{bail, Context, Result};
3use serde::Deserialize;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fs;
6use std::path::Path;
7
8#[derive(Debug, Default, Deserialize)]
9struct PackageManifest {
10    package: Option<PackageRoot>,
11}
12
13#[derive(Debug, Default, Deserialize)]
14struct PackageRoot {
15    linux: Option<DesktopPackageConfig>,
16    macos: Option<DesktopPackageConfig>,
17    windows: Option<DesktopPackageConfig>,
18}
19
20#[derive(Debug, Default, Deserialize)]
21struct DesktopPackageConfig {
22    #[serde(default)]
23    cargo_features: Vec<String>,
24    #[serde(default)]
25    cargo_no_default_features: bool,
26    #[serde(default)]
27    variants: BTreeMap<String, DesktopPackageVariant>,
28}
29
30#[derive(Debug, Default, Deserialize)]
31struct DesktopPackageVariant {
32    cargo_features: Option<Vec<String>>,
33    cargo_no_default_features: Option<bool>,
34}
35
36#[derive(Clone, Debug, Default, Eq, PartialEq)]
37pub struct DesktopCargoOptions {
38    pub features: Vec<String>,
39    pub no_default_features: bool,
40}
41
42pub fn read_desktop_cargo_options(
43    project_dir: &Path,
44    target: Target,
45    variant: Option<&str>,
46) -> Result<DesktopCargoOptions> {
47    let path = project_dir.join("fission.toml");
48    let data =
49        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
50    let manifest: PackageManifest =
51        toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
52    let config = match target {
53        Target::Linux => manifest.package.and_then(|package| package.linux),
54        Target::Macos => manifest.package.and_then(|package| package.macos),
55        Target::Windows => manifest.package.and_then(|package| package.windows),
56        _ => None,
57    };
58    let Some(config) = config else {
59        return Ok(DesktopCargoOptions::default());
60    };
61
62    let selected_variant = variant.and_then(|variant| config.variants.get(variant));
63    let configured_features = selected_variant
64        .and_then(|variant| variant.cargo_features.as_ref())
65        .unwrap_or(&config.cargo_features);
66    let mut features = BTreeSet::new();
67    for feature in configured_features {
68        validate_cargo_feature(feature)?;
69        features.insert(feature.clone());
70    }
71    Ok(DesktopCargoOptions {
72        features: features.into_iter().collect(),
73        no_default_features: selected_variant
74            .and_then(|variant| variant.cargo_no_default_features)
75            .unwrap_or(config.cargo_no_default_features),
76    })
77}
78
79fn validate_cargo_feature(feature: &str) -> Result<()> {
80    let valid = !feature.is_empty()
81        && feature.len() <= 128
82        && !feature.starts_with('-')
83        && !feature.contains(',')
84        && feature
85            .bytes()
86            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'=' | b'\\'));
87    if !valid {
88        bail!("desktop cargo feature `{feature}` is not a safe Cargo feature selector");
89    }
90    Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn selected_variant_replaces_base_features_and_default_policy() {
99        let root =
100            std::env::temp_dir().join(format!("fission-desktop-features-{}", std::process::id()));
101        let _ = fs::remove_dir_all(&root);
102        fs::create_dir_all(&root).unwrap();
103        fs::write(
104            root.join("fission.toml"),
105            r#"
106[package.macos]
107cargo_features = ["base-only"]
108
109[package.macos.variants.app-store]
110cargo_features = ["macos-app-store", "shared", "shared"]
111cargo_no_default_features = true
112
113[package.windows.variants.store]
114cargo_features = ["windows-store"]
115"#,
116        )
117        .unwrap();
118
119        assert_eq!(
120            read_desktop_cargo_options(&root, Target::Macos, Some("app-store")).unwrap(),
121            DesktopCargoOptions {
122                features: vec!["macos-app-store".to_string(), "shared".to_string()],
123                no_default_features: true,
124            }
125        );
126        assert_eq!(
127            read_desktop_cargo_options(&root, Target::Macos, None).unwrap(),
128            DesktopCargoOptions {
129                features: vec!["base-only".to_string()],
130                no_default_features: false,
131            }
132        );
133        assert_eq!(
134            read_desktop_cargo_options(&root, Target::Windows, Some("store")).unwrap(),
135            DesktopCargoOptions {
136                features: vec!["windows-store".to_string()],
137                no_default_features: false,
138            }
139        );
140        assert!(
141            read_desktop_cargo_options(&root, Target::Linux, Some("app-store"))
142                .unwrap()
143                .features
144                .is_empty()
145        );
146        let _ = fs::remove_dir_all(&root);
147    }
148
149    #[test]
150    fn unsafe_feature_selectors_are_rejected() {
151        let root = std::env::temp_dir().join(format!(
152            "fission-desktop-features-invalid-{}",
153            std::process::id()
154        ));
155        let _ = fs::remove_dir_all(&root);
156        fs::create_dir_all(&root).unwrap();
157        fs::write(
158            root.join("fission.toml"),
159            r#"
160[package.macos.variants.store]
161cargo_features = ["safe,unsafe"]
162"#,
163        )
164        .unwrap();
165
166        assert!(
167            read_desktop_cargo_options(&root, Target::Macos, Some("store"))
168                .unwrap_err()
169                .to_string()
170                .contains("safe Cargo feature selector")
171        );
172        let _ = fs::remove_dir_all(&root);
173    }
174}