1use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::error::PodboxError;
8
9const CURRENT_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SchemaVersion(u32);
16
17impl Default for SchemaVersion {
18 fn default() -> Self {
19 Self(CURRENT_SCHEMA_VERSION)
20 }
21}
22
23impl SchemaVersion {
24 pub fn as_u32(&self) -> u32 {
25 self.0
26 }
27}
28
29use super::defaults::EMBEDDED_DEFAULT;
30use super::types::{
31 ContainerConfig, DbusConfig, ImageConfig, IntegrationConfig, LifecycleConfig, NetworkConfig,
32 SecurityConfig, SystemdConfig, WaylandConfig,
33};
34
35#[derive(Debug, Deserialize, Serialize, Clone)]
36pub struct Config {
37 #[serde(default)]
38 pub schema_version: SchemaVersion,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub extends: Option<String>,
41 pub image: ImageConfig,
42 pub container: ContainerConfig,
43 #[serde(default)]
44 pub integration: IntegrationConfig,
45 #[serde(default)]
46 pub lifecycle: LifecycleConfig,
47 #[serde(default)]
48 pub systemd: SystemdConfig,
49 #[serde(default)]
50 pub network: NetworkConfig,
51 #[serde(default)]
52 pub dbus: DbusConfig,
53 #[serde(default)]
54 pub wayland: WaylandConfig,
55 #[serde(default)]
56 pub security: SecurityConfig,
57}
58
59impl Config {
60 pub fn dbus_effective_talk(&self) -> Vec<String> {
68 self.dbus.effective_talk()
69 }
70
71 pub fn dbus_portal_calls(&self) -> Vec<String> {
88 let mut rules: Vec<String> = Vec::new();
89 if self.integration.notify {
90 rules.push(
91 "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Notification.*@/org/freedesktop/portal/desktop"
92 .into(),
93 );
94 }
95 if self.integration.xdg_open {
96 rules.push(
97 "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.OpenURI.*@/org/freedesktop/portal/desktop"
98 .into(),
99 );
100 }
101 if self.integration.notify || self.integration.xdg_open {
102 rules.push(
107 "--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Request.*@/org/freedesktop/portal/desktop/request/*"
108 .into(),
109 );
110 rules.push(
111 "--broadcast=org.freedesktop.portal.Desktop=org.freedesktop.portal.Request.*@/org/freedesktop/portal/desktop/request/*"
112 .into(),
113 );
114 rules.push(
119 "--call=org.freedesktop.portal.Desktop=org.freedesktop.DBus.Introspectable.*@/org/freedesktop/portal/*"
120 .into(),
121 );
122 }
123 rules
124 }
125
126 pub fn use_dbus_proxy(&self) -> bool {
127 self.integration.dbus
128 && (!self.dbus_effective_talk().is_empty()
129 || !self.dbus_portal_calls().is_empty()
130 || !self.dbus.own.is_empty())
131 }
132
133 pub fn use_wayland_proxy(&self) -> bool {
134 self.integration.wayland && self.wayland.firewall
135 }
136
137 pub fn parse(content: &str) -> Result<Config> {
138 let mut config: Config = toml::from_str(content)
139 .with_context(|| "failed to parse definition file".to_string())?;
140 config.run_migrations();
141 config.apply_defaults();
142 config.validate()?;
143 Ok(config)
144 }
145
146 pub fn parse_with_source(path: &std::path::Path, content: &str) -> Result<Config> {
152 let merged = crate::config::extends::resolve_extends_chain(path, content)?;
153 let mut config: Config = merged
154 .try_into()
155 .with_context(|| "failed to parse merged definition file".to_string())?;
156 config.run_migrations();
157 config.apply_defaults();
158 config.validate()?;
159 Ok(config)
160 }
161
162 pub fn load(path: &std::path::Path) -> Result<Config> {
163 if !path.exists() {
164 return Err(PodboxError::DefinitionNotFound {
165 path: path.to_path_buf(),
166 }
167 .into());
168 }
169 let content = std::fs::read_to_string(path)
170 .with_context(|| format!("failed to read definition file '{}'", path.display()))?;
171 Self::parse_with_source(path, &content)
172 }
173
174 pub fn embedded() -> Config {
175 Self::parse(EMBEDDED_DEFAULT).expect("embedded default is valid TOML")
176 }
177
178 fn run_migrations(&mut self) {
180 while self.schema_version.0 < CURRENT_SCHEMA_VERSION {
181 match self.schema_version.0 {
182 0 => {} 1 => migrate_v1_to_v2(self),
184 _ => break,
185 }
186 self.schema_version.0 += 1;
187 }
188 }
189
190 fn apply_defaults(&mut self) {
191 if self.integration.dbus
192 && self.dbus.preset.is_empty()
193 && self.dbus.talk.is_empty()
194 && self.dbus.own.is_empty()
195 {
196 self.dbus.preset = "portal".into();
197 }
198 }
199}
200
201fn migrate_v1_to_v2(_config: &mut Config) {}
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::config::{GpuMode, OnStop};
207
208 #[test]
209 fn test_from_str_minimal() {
210 let toml = r#"
211[image]
212base = "fedora:41"
213name = "myenv"
214
215[container]
216name = "myenv"
217home = "~/containers/myenv"
218"#;
219 let cfg = Config::parse(toml).unwrap();
220 assert_eq!(cfg.image.base, "fedora:41");
221 assert_eq!(cfg.image.name, "myenv");
222 assert_eq!(cfg.container.name, "myenv");
223 assert_eq!(cfg.container.shell, "fish");
224 assert_eq!(cfg.integration.gpu, GpuMode::Auto);
225 assert!(cfg.integration.wayland);
226 assert!(cfg.integration.audio);
227 assert!(cfg.integration.dbus);
228 assert!(cfg.integration.notify);
229 assert!(cfg.integration.xdg_open);
230 assert!(cfg.integration.clipboard);
231 assert!(!cfg.integration.host_exec.enabled);
232 assert!(cfg.integration.host_exec.allowlist.is_none());
233 assert!(!cfg.integration.ssh_agent);
234 }
235
236 #[test]
237 fn test_home_tilde_expanded() {
238 let toml = r#"
239[image]
240base = "fedora:41"
241name = "myenv"
242
243[container]
244name = "myenv"
245home = "~/containers/myenv"
246"#;
247 let cfg = Config::parse(toml).unwrap();
248 let home = dirs::home_dir().unwrap();
249 assert!(cfg.container.home.starts_with(&home));
250 assert!(
251 cfg.container
252 .home
253 .to_string_lossy()
254 .contains("containers/myenv")
255 );
256 }
257
258 #[test]
259 fn test_on_stop_defaults_to_keep() {
260 let toml = r#"
261[image]
262base = "fedora:41"
263name = "myenv"
264
265[container]
266name = "myenv"
267home = "~/containers/myenv"
268"#;
269 let cfg = Config::parse(toml).unwrap();
270 assert_eq!(cfg.lifecycle.on_stop, OnStop::Keep);
271 }
272
273 #[test]
274 fn test_xdg_dirs_default_all_false() {
275 let toml = r#"
276[image]
277base = "fedora:41"
278name = "myenv"
279
280[container]
281name = "myenv"
282home = "~/containers/myenv"
283"#;
284 let cfg = Config::parse(toml).unwrap();
285 assert!(!cfg.integration.xdg_dirs.documents.is_enabled());
286 assert!(!cfg.integration.xdg_dirs.downloads.is_enabled());
287 assert!(!cfg.integration.xdg_dirs.pictures.is_enabled());
288 assert!(!cfg.integration.xdg_dirs.music.is_enabled());
289 assert!(!cfg.integration.xdg_dirs.videos.is_enabled());
290 assert!(!cfg.integration.xdg_dirs.desktop.is_enabled());
291 }
292
293 #[test]
294 fn test_wayland_default_is_true() {
295 let toml = r#"
296[image]
297base = "fedora:41"
298name = "myenv"
299
300[container]
301name = "myenv"
302home = "~/containers/myenv"
303"#;
304 let cfg = Config::parse(toml).unwrap();
305 assert!(cfg.integration.wayland);
306 assert!(cfg.integration.audio);
307 }
308
309 #[test]
310 fn test_embedded_default_parses() {
311 let cfg = Config::embedded();
312 assert_eq!(cfg.image.base, "fedora:44");
313 assert_eq!(cfg.image.name, "podbox");
314 assert_eq!(cfg.container.name, "podbox");
315 assert!(cfg.integration.wayland);
316 assert!(cfg.integration.audio);
317 assert!(cfg.integration.dbus);
318 assert_eq!(cfg.integration.gpu, GpuMode::Auto);
319 assert!(!cfg.lifecycle.quadlet);
320 }
321
322 #[test]
323 fn test_config_load_not_found() {
324 let path = std::path::Path::new("/tmp/does_not_exist_XXXXX.toml");
325 let result = Config::load(path);
326 assert!(result.is_err());
327 let err = result.unwrap_err();
328 assert!(err.downcast_ref::<PodboxError>().is_some());
329 }
330
331 #[test]
332 fn test_systemd_config_parses() {
333 let toml = r#"
334[image]
335base = "fedora:41"
336name = "env"
337[container]
338name = "env"
339home = "~/env"
340[systemd]
341requires = ["db.service", "cache.service"]
342after = ["network.target"]
343"#;
344 let cfg = Config::parse(toml).unwrap();
345 assert_eq!(cfg.systemd.requires, vec!["db.service", "cache.service"]);
346 assert_eq!(cfg.systemd.after, vec!["network.target"]);
347 }
348
349 #[test]
350 fn test_visual_config_parses() {
351 let toml = r#"
352[image]
353base = "fedora:41"
354name = "env"
355[container]
356name = "env"
357home = "~/env"
358[integration]
359sync_themes = true
360sync_icons = true
361sync_fonts = true
362"#;
363 let cfg = Config::parse(toml).unwrap();
364 assert!(cfg.integration.sync_themes);
365 assert!(cfg.integration.sync_icons);
366 assert!(cfg.integration.sync_fonts);
367 }
368
369 #[test]
370 fn test_dbus_config_defaults_empty() {
371 let cfg = Config::embedded();
372 assert_eq!(cfg.dbus.preset, "portal");
373 assert!(cfg.dbus_effective_talk().is_empty());
374 assert!(cfg.use_dbus_proxy());
375 let calls = cfg.dbus_portal_calls();
376 assert!(
377 calls
378 .iter()
379 .any(|r| r.contains("org.freedesktop.portal.Notification.*"))
380 );
381 assert!(
382 calls
383 .iter()
384 .any(|r| r.contains("org.freedesktop.portal.OpenURI.*"))
385 );
386 }
387
388 #[test]
389 fn test_dbus_portal_dropped_when_caps_disabled() {
390 let toml = r#"
391[image]
392base = "fedora:41"
393name = "env"
394[container]
395name = "env"
396home = "~/env"
397[dbus]
398preset = "portal"
399[integration]
400notify = false
401xdg_open = false
402clipboard = false
403"#;
404 let cfg = Config::parse(toml).unwrap();
405 assert!(cfg.dbus_effective_talk().is_empty());
406 assert!(cfg.dbus_portal_calls().is_empty());
407 assert!(!cfg.use_dbus_proxy());
408 }
409
410 #[test]
411 fn test_dbus_portal_kept_when_notify_enabled() {
412 let toml = r#"
413[image]
414base = "fedora:41"
415name = "env"
416[container]
417name = "env"
418home = "~/env"
419[dbus]
420preset = "portal"
421[integration]
422notify = true
423xdg_open = false
424clipboard = false
425"#;
426 let cfg = Config::parse(toml).unwrap();
427 assert!(cfg.dbus_effective_talk().is_empty());
428 assert!(cfg.use_dbus_proxy());
429 let calls = cfg.dbus_portal_calls();
430 assert_eq!(calls.len(), 4);
431 assert!(calls[0].starts_with("--call=org.freedesktop.portal.Desktop="));
432 assert!(calls[0].contains("org.freedesktop.portal.Notification.*"));
433 assert!(calls[1].starts_with("--call=org.freedesktop.portal.Desktop="));
434 assert!(calls[1].contains("org.freedesktop.portal.Request.*"));
435 assert!(calls[2].starts_with("--broadcast=org.freedesktop.portal.Desktop="));
436 assert!(calls[2].contains("org.freedesktop.portal.Request.*"));
437 assert!(calls[3].starts_with("--call=org.freedesktop.portal.Desktop="));
438 assert!(calls[3].contains("org.freedesktop.DBus.Introspectable.*"));
439 }
440
441 #[test]
442 fn test_dbus_portal_calls_gated_by_capabilities() {
443 let toml = r#"
444[image]
445base = "fedora:41"
446name = "env"
447[container]
448name = "env"
449home = "~/env"
450[dbus]
451preset = "portal"
452[integration]
453notify = false
454xdg_open = true
455clipboard = false
456"#;
457 let cfg = Config::parse(toml).unwrap();
458 let calls = cfg.dbus_portal_calls();
459 assert_eq!(calls.len(), 4);
460 assert!(!calls.iter().any(|r| r.contains("Notification")));
461 assert!(calls.iter().any(|r| r.contains("OpenURI.*")));
462 assert!(calls.iter().any(|r| r.contains("Introspectable")));
463 }
464
465 #[test]
466 fn test_dbus_config_parses_talk_own() {
467 let toml = r#"
468[image]
469base = "fedora:41"
470name = "env"
471[container]
472name = "env"
473home = "~/env"
474[dbus]
475talk = ["org.freedesktop.Notifications", "org.mpris.MediaPlayer2.*"]
476own = ["org.mpris.MediaPlayer2.podbox_app"]
477"#;
478 let cfg = Config::parse(toml).unwrap();
479 assert_eq!(
480 cfg.dbus.talk,
481 vec!["org.freedesktop.Notifications", "org.mpris.MediaPlayer2.*"]
482 );
483 assert_eq!(cfg.dbus.own, vec!["org.mpris.MediaPlayer2.podbox_app"]);
484 assert!(cfg.use_dbus_proxy());
485 }
486
487 #[test]
488 fn test_dbus_config_talk_only() {
489 let toml = r#"
490[image]
491base = "fedora:41"
492name = "env"
493[container]
494name = "env"
495home = "~/env"
496[dbus]
497talk = ["org.freedesktop.Notifications"]
498"#;
499 let cfg = Config::parse(toml).unwrap();
500 assert_eq!(cfg.dbus.talk.len(), 1);
501 assert!(cfg.dbus.own.is_empty());
502 assert!(cfg.use_dbus_proxy());
503 }
504
505 #[test]
506 fn test_dbus_config_own_only() {
507 let toml = r#"
508[image]
509base = "fedora:41"
510name = "env"
511[container]
512name = "env"
513home = "~/env"
514[dbus]
515own = ["org.example.Service"]
516"#;
517 let cfg = Config::parse(toml).unwrap();
518 assert!(cfg.dbus.talk.is_empty());
519 assert_eq!(cfg.dbus.own.len(), 1);
520 assert!(cfg.use_dbus_proxy());
521 }
522
523 #[test]
524 fn test_invalid_toml_errors() {
525 let toml = r#"
526[image
527base = "fedora:41"
528"#;
529 assert!(Config::parse(toml).is_err());
530 }
531
532 #[test]
533 fn test_missing_required_fields_errors() {
534 let toml = r#"
535[image]
536base = "fedora:41"
537"#;
538 assert!(Config::parse(toml).is_err());
539 }
540
541 #[test]
542 fn test_network_defaults_to_private() {
543 let toml = r#"
544[image]
545base = "fedora:41"
546name = "env"
547[container]
548name = "env"
549home = "~/env"
550"#;
551 let cfg = Config::parse(toml).unwrap();
552 assert_eq!(cfg.network.mode, "private");
553 assert!(cfg.network.ports.is_empty());
554 }
555
556 #[test]
557 fn test_network_parses_mode_and_ports() {
558 let toml = r#"
559[image]
560base = "fedora:41"
561name = "env"
562[container]
563name = "env"
564home = "~/env"
565[network]
566mode = "pasta"
567ports = ["8080:80", "443:443"]
568"#;
569 let cfg = Config::parse(toml).unwrap();
570 assert_eq!(cfg.network.mode, "pasta");
571 assert_eq!(cfg.network.ports, vec!["8080:80", "443:443"]);
572 }
573
574 #[test]
575 fn test_network_invalid_mode_rejected() {
576 let toml = r#"
577[image]
578base = "fedora:41"
579name = "env"
580[container]
581name = "env"
582home = "~/env"
583[network]
584mode = "macvlan"
585"#;
586 assert!(Config::parse(toml).is_err());
587 }
588
589 #[test]
590 fn test_network_port_missing_separator_rejected() {
591 let toml = r#"
592[image]
593base = "fedora:41"
594name = "env"
595[container]
596name = "env"
597home = "~/env"
598[network]
599mode = "bridge"
600ports = ["8080"]
601"#;
602 assert!(Config::parse(toml).is_err());
603 }
604
605 #[test]
606 fn test_memory_decimal_rejected() {
607 let toml = r#"
608[image]
609base = "fedora:41"
610name = "env"
611[container]
612name = "env"
613home = "~/env"
614memory = "1.5g"
615"#;
616 let cfg = Config::parse(toml);
617 assert!(cfg.is_err(), "decimal memory should be rejected: {cfg:?}");
618 }
619
620 #[test]
621 fn test_memory_integer_accepted() {
622 let toml = r#"
623[image]
624base = "fedora:41"
625name = "env"
626[container]
627name = "env"
628home = "~/env"
629memory = "2g"
630"#;
631 assert!(Config::parse(toml).is_ok());
632 }
633
634 #[test]
635 fn test_cpus_parses_valid() {
636 let toml = r#"
637[image]
638base = "fedora:41"
639name = "env"
640[container]
641name = "env"
642home = "~/env"
643cpus = "2.0"
644"#;
645 let cfg = Config::parse(toml).unwrap();
646 assert_eq!(cfg.container.cpus.as_deref(), Some("2.0"));
647 }
648
649 #[test]
650 fn test_cpus_rejects_non_positive() {
651 let toml = r#"
652[image]
653base = "fedora:41"
654name = "env"
655[container]
656name = "env"
657home = "~/env"
658cpus = "0"
659"#;
660 assert!(Config::parse(toml).is_err());
661 }
662
663 #[test]
664 fn test_cpus_defaults_to_none() {
665 let toml = r#"
666[image]
667base = "fedora:41"
668name = "env"
669[container]
670name = "env"
671home = "~/env"
672"#;
673 let cfg = Config::parse(toml).unwrap();
674 assert!(cfg.container.cpus.is_none());
675 }
676
677 #[test]
678 fn test_security_read_only_rootfs_defaults_false() {
679 let cfg = Config::embedded();
680 assert!(!cfg.security.read_only_rootfs);
681 }
682
683 #[test]
684 fn test_security_userns_defaults_none() {
685 let cfg = Config::embedded();
686 assert!(cfg.security.userns.is_none());
687 }
688
689 #[test]
690 fn test_security_userns_valid_modes() {
691 for mode in &["keep-id", "nomap", "private"] {
692 let toml = format!(
693 r#"
694[image]
695base = "fedora:41"
696name = "env"
697[container]
698name = "env"
699home = "~/env"
700[security]
701userns = "{mode}"
702"#
703 );
704 assert!(
705 Config::parse(&toml).is_ok(),
706 "userns mode '{mode}' should be valid"
707 );
708 }
709 }
710
711 #[test]
712 fn test_security_userns_invalid_mode_rejected() {
713 let toml = r#"
714[image]
715base = "fedora:41"
716name = "env"
717[container]
718name = "env"
719home = "~/env"
720[security]
721userns = "invalid"
722"#;
723 assert!(Config::parse(toml).is_err());
724 }
725
726 #[test]
727 fn test_schema_version_defaults_to_current() {
728 let cfg = Config::embedded();
729 assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
730 }
731
732 #[test]
733 fn test_schema_version_parsed_from_toml() {
734 let toml = r#"
735schema_version = 1
736[image]
737base = "fedora:41"
738name = "env"
739[container]
740name = "env"
741home = "~/env"
742"#;
743 let cfg = Config::parse(toml).unwrap();
744 assert_eq!(cfg.schema_version.as_u32(), 1);
745 }
746
747 #[test]
748 fn test_schema_version_defaults_when_omitted() {
749 let toml = r#"
750[image]
751base = "fedora:41"
752name = "env"
753[container]
754name = "env"
755home = "~/env"
756"#;
757 let cfg = Config::parse(toml).unwrap();
758 assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
759 }
760
761 #[test]
762 fn test_schema_version_migration_bumps_old_schema() {
763 let toml = r#"
764schema_version = 0
765[image]
766base = "fedora:41"
767name = "env"
768[container]
769name = "env"
770home = "~/env"
771"#;
772 let cfg = Config::parse(toml).unwrap();
773 assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
774 }
775}