Skip to main content

podbox/config/
schema.rs

1//! `Config` — the podbox definition schema, its parse → migrate →
2//! defaults → validate pipeline, and its migrations.
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7use crate::error::PodboxError;
8
9/// Latest config schema version. Increment when making a backwards-incompatible
10/// change, and add a migration function in `run_migrations`.
11const CURRENT_SCHEMA_VERSION: u32 = 1;
12
13/// Schema version newtype with a default of 1.
14#[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    /// Effective D-Bus talk list.
61    ///
62    /// The `portal` preset no longer contributes `org.freedesktop.portal.*`
63    /// here — the portal name is exposed through interface-scoped
64    /// `--call=`/`--broadcast=` rules instead (see [`Self::dbus_portal_calls`]),
65    /// so host-privileged portal interfaces (DynamicLauncher, Screenshot,
66    /// ScreenCast, Settings, ...) are unreachable from the container.
67    pub fn dbus_effective_talk(&self) -> Vec<String> {
68        self.dbus.effective_talk()
69    }
70
71    /// Interface-scoped `--call=`/`--broadcast=` rules for the XDG portal name.
72    ///
73    /// The portal preset no longer grants `org.freedesktop.portal.*` wholesale
74    /// via `--talk=`. Instead, only the interfaces actually needed by the
75    /// enabled capabilities are exposed as `xdg-dbus-proxy` rules scoped to
76    /// `org.freedesktop.portal.Desktop`:
77    ///
78    /// - `integration.notify` → `org.freedesktop.portal.Notification.*`
79    /// - `integration.xdg_open` → `org.freedesktop.portal.OpenURI.*`
80    ///
81    /// Portals use the async Request pattern, so the `Request` interface on the
82    /// `/org/freedesktop/portal/desktop/request/*` subtree is always allowed
83    /// alongside (method calls for `Request.Close`, and the `Request.Response`
84    /// broadcast signal that carries the actual result). A read-only
85    /// `org.freedesktop.DBus.Introspectable` rule is added so GIO-based clients
86    /// can introspect the service (gdbus needs the XML to parse arguments).
87    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            // Portals use the async Request pattern, so the `Request` interface
103            // on the `/org/freedesktop/portal/desktop/request/*` subtree is
104            // always allowed alongside (method calls for `Request.Close`, and
105            // the `Request.Response` signal that carries the actual result).
106            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            // GIO-based clients introspect the service before calling (gdbus
115            // uses the resulting XML to parse arguments). Introspection is
116            // read-only (returns interface metadata) and doesn't grant any
117            // method access, so it is allowed over the portal subtree.
118            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    /// Parse with `extends` resolution anchored at `path`.
147    ///
148    /// Relative / sibling / profile extends are resolved; the merged TOML is
149    /// deserialized as a single `Config`. Source-less `parse` keeps its old
150    /// behaviour for tests/embedded.
151    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    /// Run migration chain up to `CURRENT_SCHEMA_VERSION`.
179    fn run_migrations(&mut self) {
180        while self.schema_version.0 < CURRENT_SCHEMA_VERSION {
181            match self.schema_version.0 {
182                0 => {} // v0 was never released; silently bump to v1.
183                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
201/// Placeholder migration — no changes from v1 to v2 yet.
202fn 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_memory_bare_digits_rejected() {
636        let toml = r#"
637[image]
638base = "fedora:41"
639name = "env"
640[container]
641name = "env"
642home = "~/env"
643memory = "2"
644"#;
645        let cfg = Config::parse(toml);
646        assert!(
647            cfg.is_err(),
648            "bare memory without unit should be rejected: {cfg:?}"
649        );
650        let err = format!("{cfg:?}");
651        assert!(err.contains("container.memory"));
652    }
653
654    #[test]
655    fn test_memory_bare_digits_helper() {
656        use crate::config::validation::{is_bare_memory_digits, is_valid_memory};
657        assert!(is_bare_memory_digits("2"));
658        assert!(is_bare_memory_digits("  512  "));
659        assert!(!is_bare_memory_digits("2g"));
660        assert!(!is_valid_memory("2"));
661        assert!(is_valid_memory("2G"));
662        assert!(is_valid_memory("512m"));
663    }
664
665    #[test]
666    fn test_cpus_parses_valid() {
667        let toml = r#"
668[image]
669base = "fedora:41"
670name = "env"
671[container]
672name = "env"
673home = "~/env"
674cpus = "2.0"
675"#;
676        let cfg = Config::parse(toml).unwrap();
677        assert_eq!(cfg.container.cpus.as_deref(), Some("2.0"));
678    }
679
680    #[test]
681    fn test_cpus_rejects_non_positive() {
682        let toml = r#"
683[image]
684base = "fedora:41"
685name = "env"
686[container]
687name = "env"
688home = "~/env"
689cpus = "0"
690"#;
691        assert!(Config::parse(toml).is_err());
692    }
693
694    #[test]
695    fn test_cpus_defaults_to_none() {
696        let toml = r#"
697[image]
698base = "fedora:41"
699name = "env"
700[container]
701name = "env"
702home = "~/env"
703"#;
704        let cfg = Config::parse(toml).unwrap();
705        assert!(cfg.container.cpus.is_none());
706    }
707
708    #[test]
709    fn test_security_read_only_rootfs_defaults_false() {
710        let cfg = Config::embedded();
711        assert!(!cfg.security.read_only_rootfs);
712    }
713
714    #[test]
715    fn test_security_userns_defaults_none() {
716        let cfg = Config::embedded();
717        assert!(cfg.security.userns.is_none());
718    }
719
720    #[test]
721    fn test_security_userns_valid_modes() {
722        for mode in &["keep-id", "nomap", "private"] {
723            let toml = format!(
724                r#"
725[image]
726base = "fedora:41"
727name = "env"
728[container]
729name = "env"
730home = "~/env"
731[security]
732userns = "{mode}"
733"#
734            );
735            assert!(
736                Config::parse(&toml).is_ok(),
737                "userns mode '{mode}' should be valid"
738            );
739        }
740    }
741
742    #[test]
743    fn test_security_userns_invalid_mode_rejected() {
744        let toml = r#"
745[image]
746base = "fedora:41"
747name = "env"
748[container]
749name = "env"
750home = "~/env"
751[security]
752userns = "invalid"
753"#;
754        assert!(Config::parse(toml).is_err());
755    }
756
757    #[test]
758    fn test_schema_version_defaults_to_current() {
759        let cfg = Config::embedded();
760        assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
761    }
762
763    #[test]
764    fn test_schema_version_parsed_from_toml() {
765        let toml = r#"
766schema_version = 1
767[image]
768base = "fedora:41"
769name = "env"
770[container]
771name = "env"
772home = "~/env"
773"#;
774        let cfg = Config::parse(toml).unwrap();
775        assert_eq!(cfg.schema_version.as_u32(), 1);
776    }
777
778    #[test]
779    fn test_schema_version_defaults_when_omitted() {
780        let toml = r#"
781[image]
782base = "fedora:41"
783name = "env"
784[container]
785name = "env"
786home = "~/env"
787"#;
788        let cfg = Config::parse(toml).unwrap();
789        assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
790    }
791
792    #[test]
793    fn test_schema_version_migration_bumps_old_schema() {
794        let toml = r#"
795schema_version = 0
796[image]
797base = "fedora:41"
798name = "env"
799[container]
800name = "env"
801home = "~/env"
802"#;
803        let cfg = Config::parse(toml).unwrap();
804        assert_eq!(cfg.schema_version.as_u32(), CURRENT_SCHEMA_VERSION);
805    }
806}