Skip to main content

origin_manifest/
lib.rs

1//! The app manifest (ADR-0021).
2//!
3//! `app.toml` answers *what is this product?* The composition root answers *how is it
4//! assembled?* Everything that can be derived from the first is generated rather than
5//! written by hand โ€” generated files can be updated without merge conflicts, which is
6//! what makes an Origin upgrade cheap.
7//!
8//! The format is deliberately small. Everything in it becomes migration-liable the
9//! moment a second product exists.
10
11mod distribution;
12mod profile;
13mod security;
14
15pub use distribution::{Channel, DistributionSection, Target, UpdaterSection};
16pub use profile::SecurityProfile;
17pub use security::Capability;
18
19use serde::{Deserialize, Serialize};
20use std::collections::BTreeMap;
21use std::path::Path;
22
23#[derive(Debug, thiserror::Error)]
24pub enum ManifestError {
25    #[error("cannot read {path}: {source}")]
26    Read {
27        path: String,
28        #[source]
29        source: std::io::Error,
30    },
31
32    #[error("{path} is not valid TOML: {source}")]
33    Parse {
34        path: String,
35        #[source]
36        source: toml::de::Error,
37    },
38
39    #[error("{path}: {message}")]
40    Invalid { path: String, message: String },
41}
42
43/// A parsed `app.toml`.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Manifest {
46    pub origin: OriginSection,
47    pub product: ProductSection,
48    #[serde(default)]
49    pub platform: PlatformSection,
50    /// Modules the product compiles in. The value switches a module on or off.
51    #[serde(default)]
52    pub modules: BTreeMap<String, bool>,
53    #[serde(default)]
54    pub security: SecuritySection,
55    #[serde(default)]
56    pub distribution: DistributionSection,
57}
58
59/// Which Origin version this product tracks.
60///
61/// Read by `origin update` to decide which migrations still have to run.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct OriginSection {
64    pub version: String,
65    /// Deliberate deviations from an Origin recommendation (ยง46).
66    ///
67    /// A migration skips whatever is listed here and reports it as a manual step
68    /// instead of overwriting a decision someone made on purpose.
69    #[serde(default)]
70    pub overrides: BTreeMap<String, bool>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ProductSection {
75    /// Reverse-DNS identifier. Also scopes credentials in the OS keychain.
76    pub id: String,
77    pub name: String,
78    pub version: String,
79    #[serde(default)]
80    pub description: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(default)]
85pub struct PlatformSection {
86    pub tray: bool,
87    pub autostart: bool,
88    pub notifications: bool,
89    pub updater: bool,
90    pub single_instance: bool,
91    pub window_state: bool,
92}
93
94impl Default for PlatformSection {
95    fn default() -> Self {
96        Self {
97            tray: false,
98            autostart: false,
99            notifications: true,
100            updater: false,
101            // On by default: two instances of the same desktop app fighting over one
102            // database is a bug in every product, not a per-product choice.
103            single_instance: true,
104            window_state: true,
105        }
106    }
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct SecuritySection {
111    /// Security profile per window label (ADR-0007).
112    #[serde(default)]
113    pub windows: BTreeMap<String, WindowSecurity>,
114    /// Permitted external processes (B1).
115    #[serde(default)]
116    pub process: ProcessSecurity,
117}
118
119/// Allowed programs for the process runner contract (B1).
120///
121/// Declared in `app.toml` under `[security.process]`.
122#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
123pub struct ProcessSecurity {
124    /// Programs permitted to run. Must be pure executable names without path separators.
125    #[serde(default)]
126    pub allowed_programs: Vec<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct WindowSecurity {
131    pub profile: SecurityProfile,
132}
133
134impl Manifest {
135    pub fn load(path: impl AsRef<Path>) -> Result<Self, ManifestError> {
136        let path = path.as_ref();
137        let display = path.display().to_string();
138
139        let contents = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
140            path: display.clone(),
141            source,
142        })?;
143
144        let manifest: Self = toml::from_str(&contents).map_err(|source| ManifestError::Parse {
145            path: display.clone(),
146            source,
147        })?;
148
149        manifest.validate(&display)?;
150        Ok(manifest)
151    }
152
153    /// Checks that cannot be expressed in the type system.
154    fn validate(&self, path: &str) -> Result<(), ManifestError> {
155        let invalid = |message: String| ManifestError::Invalid {
156            path: path.to_owned(),
157            message,
158        };
159
160        if self.product.id.split('.').count() < 2 {
161            return Err(invalid(format!(
162                "product.id must be reverse-DNS, got `{}`",
163                self.product.id
164            )));
165        }
166
167        if self.security.windows.is_empty() {
168            return Err(invalid(
169                "security.windows is empty โ€” every window needs an explicit security \
170                 profile, and a product with no windows cannot be shown"
171                    .to_owned(),
172            ));
173        }
174
175        // An updater without a verifiable endpoint is worse than none: it turns a
176        // compromised host into arbitrary code execution on every installation.
177        if self.distribution.updater.enabled {
178            if self.distribution.updater.endpoints.is_empty() {
179                return Err(invalid(
180                    "distribution.updater is enabled but declares no endpoints".to_owned(),
181                ));
182            }
183
184            if let Some(insecure) = self
185                .distribution
186                .updater
187                .endpoints
188                .iter()
189                .find(|endpoint| !endpoint.starts_with("https://"))
190            {
191                return Err(invalid(format!(
192                    "update endpoint `{insecure}` is not https โ€” an update channel that \
193                     can be intercepted is a code execution channel"
194                )));
195            }
196        }
197
198        // A tray application that quits with its last window has no tray to speak of;
199        // catching that here is cheaper than a bug report about it.
200        if self.platform.tray && !self.platform.single_instance {
201            return Err(invalid(
202                "platform.tray with single_instance = false: a second instance would add \
203                 a second tray icon"
204                    .to_owned(),
205            ));
206        }
207
208        for program in &self.security.process.allowed_programs {
209            if program.trim().is_empty() {
210                return Err(invalid(
211                    "security.process.allowed_programs contains an empty program name".to_owned(),
212                ));
213            }
214            if program.contains('/') || program.contains('\\') {
215                return Err(invalid(format!(
216                    "security.process.allowed_programs entry `{program}` must be a program name, not a path"
217                )));
218            }
219        }
220
221        Ok(())
222    }
223
224    /// Modules switched on, in a stable order.
225    pub fn enabled_modules(&self) -> Vec<&str> {
226        self.modules
227            .iter()
228            .filter(|(_, enabled)| **enabled)
229            .map(|(name, _)| name.as_str())
230            .collect()
231    }
232
233    pub fn has_override(&self, key: &str) -> bool {
234        self.overrides_contains(key)
235    }
236
237    fn overrides_contains(&self, key: &str) -> bool {
238        self.origin.overrides.get(key).copied().unwrap_or(false)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn manifest(extra: &str) -> Result<Manifest, ManifestError> {
247        let contents = format!(
248            r#"
249[origin]
250version = "0.1.0"
251
252[product]
253id = "dev.origin.demo"
254name = "Origin Demo"
255version = "0.1.0"
256
257[security.windows.main]
258profile = "standard-dashboard"
259{extra}
260"#
261        );
262
263        let parsed: Manifest =
264            toml::from_str(&contents).map_err(|source| ManifestError::Parse {
265                path: "test".to_owned(),
266                source,
267            })?;
268        parsed.validate("test")?;
269        Ok(parsed)
270    }
271
272    #[test]
273    fn a_minimal_manifest_parses_with_sensible_defaults() {
274        let manifest = manifest("").unwrap();
275
276        assert_eq!(manifest.product.id, "dev.origin.demo");
277        assert!(manifest.platform.single_instance);
278        assert!(manifest.platform.notifications);
279        assert!(!manifest.platform.tray);
280    }
281
282    #[test]
283    fn only_enabled_modules_are_listed_and_the_order_is_stable() {
284        let manifest =
285            manifest("\n[modules]\npulse = true\nlegacy = false\ninbox = true\n").unwrap();
286
287        assert_eq!(manifest.enabled_modules(), vec!["inbox", "pulse"]);
288    }
289
290    #[test]
291    fn a_product_id_that_is_not_reverse_dns_is_rejected() {
292        let contents = r#"
293[origin]
294version = "0.1.0"
295
296[product]
297id = "demo"
298name = "Demo"
299version = "0.1.0"
300
301[security.windows.main]
302profile = "standard-dashboard"
303"#;
304        let parsed: Manifest = toml::from_str(contents).unwrap();
305
306        let error = parsed.validate("test").unwrap_err();
307        assert!(error.to_string().contains("reverse-DNS"), "got: {error}");
308    }
309
310    #[test]
311    fn a_window_without_a_security_profile_cannot_exist() {
312        let contents = r#"
313[origin]
314version = "0.1.0"
315
316[product]
317id = "dev.origin.demo"
318name = "Demo"
319version = "0.1.0"
320"#;
321        let parsed: Manifest = toml::from_str(contents).unwrap();
322
323        let error = parsed.validate("test").unwrap_err();
324        assert!(
325            error.to_string().contains("security profile"),
326            "got: {error}"
327        );
328    }
329
330    #[test]
331    fn a_tray_app_that_allows_second_instances_is_rejected() {
332        let error = manifest("\n[platform]\ntray = true\nsingle_instance = false\n").unwrap_err();
333
334        assert!(
335            error.to_string().contains("second tray icon"),
336            "got: {error}"
337        );
338    }
339
340    #[test]
341    fn overrides_default_to_absent() {
342        let manifest = manifest("").unwrap();
343        assert!(!manifest.has_override("custom_window_management"));
344
345        let manifest =
346            manifest_with_override("\n[origin.overrides]\ncustom_window_management = true\n");
347        assert!(manifest.has_override("custom_window_management"));
348    }
349
350    #[test]
351    fn process_allowlist_parses_allowed_programs() {
352        let manifest =
353            manifest("\n[security.process]\nallowed_programs = [\"git\", \"code\"]\n").unwrap();
354        assert_eq!(
355            manifest.security.process.allowed_programs,
356            vec!["git", "code"]
357        );
358    }
359
360    #[test]
361    fn process_allowlist_rejects_paths_or_empty_names() {
362        let err_path =
363            manifest("\n[security.process]\nallowed_programs = [\"/usr/bin/git\"]\n").unwrap_err();
364        assert!(
365            err_path
366                .to_string()
367                .contains("must be a program name, not a path")
368        );
369
370        let err_empty =
371            manifest("\n[security.process]\nallowed_programs = [\"   \"]\n").unwrap_err();
372        assert!(err_empty.to_string().contains("empty program name"));
373    }
374
375    fn manifest_with_override(extra: &str) -> Manifest {
376        let contents = format!(
377            r#"
378[origin]
379version = "0.1.0"
380{extra}
381
382[product]
383id = "dev.origin.demo"
384name = "Demo"
385version = "0.1.0"
386
387[security.windows.main]
388profile = "standard-dashboard"
389"#
390        );
391        toml::from_str(&contents).unwrap()
392    }
393}