Skip to main content

podbox/config/
mod.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use crate::error::PodboxError;
5
6/// Latest config schema version. Increment when making a backwards-incompatible
7/// change, and add a migration function in `run_migrations`.
8const CURRENT_SCHEMA_VERSION: u32 = 1;
9
10/// Schema version newtype with a default of 1.
11#[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    /// Effective D-Bus talk list.
68    ///
69    /// The `portal` preset no longer contributes `org.freedesktop.portal.*`
70    /// here — the portal name is exposed through interface-scoped
71    /// `--call=`/`--broadcast=` rules instead (see [`Self::dbus_portal_calls`]),
72    /// so host-privileged portal interfaces (DynamicLauncher, Screenshot,
73    /// ScreenCast, Settings, ...) are unreachable from the container.
74    pub fn dbus_effective_talk(&self) -> Vec<String> {
75        self.dbus.effective_talk()
76    }
77
78    /// Interface-scoped `--call=`/`--broadcast=` rules for the XDG portal name.
79    ///
80    /// The portal preset no longer grants `org.freedesktop.portal.*` wholesale
81    /// via `--talk=`. Instead, only the interfaces actually needed by the
82    /// enabled capabilities are exposed as `xdg-dbus-proxy` rules scoped to
83    /// `org.freedesktop.portal.Desktop`:
84    ///
85    /// - `integration.notify` → `org.freedesktop.portal.Notification.*`
86    /// - `integration.xdg_open` → `org.freedesktop.portal.OpenURI.*`
87    ///
88    /// Portals use the async Request pattern, so the `Request` interface on the
89    /// `/org/freedesktop/portal/desktop/request/*` subtree is always allowed
90    /// alongside (method calls for `Request.Close`, and the `Request.Response`
91    /// broadcast signal that carries the actual result). A read-only
92    /// `org.freedesktop.DBus.Introspectable` rule is added so GIO-based clients
93    /// can introspect the service (gdbus needs the XML to parse arguments).
94    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            // Portals use the async Request pattern, so the `Request` interface
110            // on the `/org/freedesktop/portal/desktop/request/*` subtree is
111            // always allowed alongside (method calls for `Request.Close`, and
112            // the `Request.Response` signal that carries the actual result).
113            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            // GIO-based clients introspect the service before calling (gdbus
122            // uses the resulting XML to parse arguments). Introspection is
123            // read-only (returns interface metadata) and doesn't grant any
124            // method access, so it is allowed over the portal subtree.
125            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    /// Run migration chain up to `CURRENT_SCHEMA_VERSION`.
170    fn run_migrations(&mut self) {
171        while self.schema_version.0 < CURRENT_SCHEMA_VERSION {
172            match self.schema_version.0 {
173                0 => {} // v0 was never released; silently bump to v1.
174                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
192/// Placeholder migration — no changes from v1 to v2 yet.
193fn 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}