1mod 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#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Manifest {
46 pub origin: OriginSection,
47 pub product: ProductSection,
48 #[serde(default)]
49 pub platform: PlatformSection,
50 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct OriginSection {
64 pub version: String,
65 #[serde(default)]
70 pub overrides: BTreeMap<String, bool>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ProductSection {
75 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 single_instance: true,
104 window_state: true,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct SecuritySection {
111 #[serde(default)]
113 pub windows: BTreeMap<String, WindowSecurity>,
114 #[serde(default)]
116 pub process: ProcessSecurity,
117}
118
119#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
123pub struct ProcessSecurity {
124 #[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 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 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 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 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}