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