bootc_lib/install/
config.rs

1//! # Configuration for `bootc install`
2//!
3//! This module handles the TOML configuration file for `bootc install`.
4
5use anyhow::{Context, Result};
6use clap::ValueEnum;
7use fn_error_context::context;
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "install-to-disk")]
11use super::baseline::BlockSetup;
12
13/// Properties of the environment, such as the system architecture
14/// Left open for future properties such as `platform.id`
15pub(crate) struct EnvProperties {
16    pub(crate) sys_arch: String,
17}
18
19/// A well known filesystem type.
20#[derive(clap::ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub(crate) enum Filesystem {
23    Xfs,
24    Ext4,
25    Btrfs,
26}
27
28impl std::fmt::Display for Filesystem {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        self.to_possible_value().unwrap().get_name().fmt(f)
31    }
32}
33
34/// The toplevel config entry for installation configs stored
35/// in bootc/install (e.g. /etc/bootc/install/05-custom.toml)
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37#[serde(deny_unknown_fields)]
38pub(crate) struct InstallConfigurationToplevel {
39    pub(crate) install: Option<InstallConfiguration>,
40}
41
42/// Configuration for a filesystem
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44#[serde(deny_unknown_fields)]
45pub(crate) struct RootFS {
46    #[serde(rename = "type")]
47    pub(crate) fstype: Option<Filesystem>,
48}
49
50/// This structure should only define "system" or "basic" filesystems; we are
51/// not trying to generalize this into e.g. supporting `/var` or other ones.
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53#[serde(deny_unknown_fields)]
54pub(crate) struct BasicFilesystems {
55    pub(crate) root: Option<RootFS>,
56    // TODO allow configuration of these other filesystems too
57    // pub(crate) xbootldr: Option<FilesystemCustomization>,
58    // pub(crate) esp: Option<FilesystemCustomization>,
59}
60
61/// Configuration for ostree repository
62pub(crate) type OstreeRepoOpts = ostree_ext::repo_options::RepoOptions;
63
64/// Configuration options for bootupd, responsible for setting up the bootloader.
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66#[serde(rename_all = "kebab-case", deny_unknown_fields)]
67pub(crate) struct Bootupd {
68    /// Whether to skip writing the boot partition UUID to the bootloader configuration.
69    /// When true, bootupd is invoked with `--with-static-configs` instead of `--write-uuid`.
70    /// Defaults to false (UUIDs are written by default).
71    pub(crate) skip_boot_uuid: Option<bool>,
72}
73
74/// The serialized `[install]` section
75#[derive(Debug, Clone, Serialize, Deserialize, Default)]
76#[serde(rename = "install", rename_all = "kebab-case", deny_unknown_fields)]
77pub(crate) struct InstallConfiguration {
78    /// Root filesystem type
79    pub(crate) root_fs_type: Option<Filesystem>,
80    /// Enabled block storage configurations
81    #[cfg(feature = "install-to-disk")]
82    pub(crate) block: Option<Vec<BlockSetup>>,
83    pub(crate) filesystem: Option<BasicFilesystems>,
84    /// Kernel arguments, applied at installation time
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub(crate) kargs: Option<Vec<String>>,
87    /// Supported architectures for this configuration
88    pub(crate) match_architectures: Option<Vec<String>>,
89    /// Ostree repository configuration
90    pub(crate) ostree: Option<OstreeRepoOpts>,
91    /// The stateroot name to use. Defaults to `default`
92    pub(crate) stateroot: Option<String>,
93    /// Source device specification for the root filesystem.
94    /// For example, `UUID=2e9f4241-229b-4202-8429-62d2302382e1` or `LABEL=rootfs`.
95    pub(crate) root_mount_spec: Option<String>,
96    /// Mount specification for the /boot filesystem.
97    pub(crate) boot_mount_spec: Option<String>,
98    /// Bootupd configuration
99    pub(crate) bootupd: Option<Bootupd>,
100}
101
102fn merge_basic<T>(s: &mut Option<T>, o: Option<T>, _env: &EnvProperties) {
103    if let Some(o) = o {
104        *s = Some(o);
105    }
106}
107
108trait Mergeable {
109    fn merge(&mut self, other: Self, env: &EnvProperties)
110    where
111        Self: Sized;
112}
113
114impl<T> Mergeable for Option<T>
115where
116    T: Mergeable,
117{
118    fn merge(&mut self, other: Self, env: &EnvProperties)
119    where
120        Self: Sized,
121    {
122        if let Some(other) = other {
123            if let Some(s) = self.as_mut() {
124                s.merge(other, env)
125            } else {
126                *self = Some(other);
127            }
128        }
129    }
130}
131
132impl Mergeable for RootFS {
133    /// Apply any values in other, overriding any existing values in `self`.
134    fn merge(&mut self, other: Self, env: &EnvProperties) {
135        merge_basic(&mut self.fstype, other.fstype, env)
136    }
137}
138
139impl Mergeable for BasicFilesystems {
140    /// Apply any values in other, overriding any existing values in `self`.
141    fn merge(&mut self, other: Self, env: &EnvProperties) {
142        self.root.merge(other.root, env)
143    }
144}
145
146impl Mergeable for OstreeRepoOpts {
147    /// Apply any values in other, overriding any existing values in `self`.
148    fn merge(&mut self, other: Self, env: &EnvProperties) {
149        merge_basic(
150            &mut self.bls_append_except_default,
151            other.bls_append_except_default,
152            env,
153        )
154    }
155}
156
157impl Mergeable for Bootupd {
158    /// Apply any values in other, overriding any existing values in `self`.
159    fn merge(&mut self, other: Self, env: &EnvProperties) {
160        merge_basic(&mut self.skip_boot_uuid, other.skip_boot_uuid, env)
161    }
162}
163
164impl Mergeable for InstallConfiguration {
165    /// Apply any values in other, overriding any existing values in `self`.
166    fn merge(&mut self, other: Self, env: &EnvProperties) {
167        // if arch is specified, only merge config if it matches the current arch
168        // if arch is not specified, merge config unconditionally
169        if other
170            .match_architectures
171            .map(|a| a.contains(&env.sys_arch))
172            .unwrap_or(true)
173        {
174            merge_basic(&mut self.root_fs_type, other.root_fs_type, env);
175            #[cfg(feature = "install-to-disk")]
176            merge_basic(&mut self.block, other.block, env);
177            self.filesystem.merge(other.filesystem, env);
178            self.ostree.merge(other.ostree, env);
179            merge_basic(&mut self.stateroot, other.stateroot, env);
180            merge_basic(&mut self.root_mount_spec, other.root_mount_spec, env);
181            merge_basic(&mut self.boot_mount_spec, other.boot_mount_spec, env);
182            self.bootupd.merge(other.bootupd, env);
183            if let Some(other_kargs) = other.kargs {
184                self.kargs
185                    .get_or_insert_with(Default::default)
186                    .extend(other_kargs)
187            }
188        }
189    }
190}
191
192impl InstallConfiguration {
193    /// Set defaults (e.g. `block`), and also handle fields that can be specified multiple ways
194    /// by synchronizing the values of the fields to ensure they're the same.
195    ///
196    /// - install.root-fs-type is synchronized with install.filesystems.root.type; if
197    ///   both are set, then the latter takes precedence
198    pub(crate) fn canonicalize(&mut self) {
199        // New canonical form wins.
200        if let Some(rootfs_type) = self.filesystem_root().and_then(|f| f.fstype.as_ref()) {
201            self.root_fs_type = Some(*rootfs_type)
202        } else if let Some(rootfs) = self.root_fs_type.as_ref() {
203            let fs = self.filesystem.get_or_insert_with(Default::default);
204            let root = fs.root.get_or_insert_with(Default::default);
205            root.fstype = Some(*rootfs);
206        }
207
208        #[cfg(feature = "install-to-disk")]
209        if self.block.is_none() {
210            self.block = Some(vec![BlockSetup::Direct]);
211        }
212    }
213
214    /// Convenience helper to access the root filesystem
215    pub(crate) fn filesystem_root(&self) -> Option<&RootFS> {
216        self.filesystem.as_ref().and_then(|fs| fs.root.as_ref())
217    }
218
219    // Remove all configuration which is handled by `install to-filesystem`.
220    pub(crate) fn filter_to_external(&mut self) {
221        self.kargs.take();
222    }
223
224    #[cfg(feature = "install-to-disk")]
225    pub(crate) fn get_block_setup(&self, default: Option<BlockSetup>) -> Result<BlockSetup> {
226        let valid_block_setups = self.block.as_deref().unwrap_or_default();
227        let default_block = valid_block_setups.iter().next().ok_or_else(|| {
228            anyhow::anyhow!("Empty block storage configuration in install configuration")
229        })?;
230        let block_setup = default.as_ref().unwrap_or(default_block);
231        if !valid_block_setups.contains(block_setup) {
232            anyhow::bail!("Block setup {block_setup:?} is not enabled in installation config");
233        }
234        Ok(*block_setup)
235    }
236}
237
238#[context("Loading configuration")]
239/// Load the install configuration, merging all found configuration files.
240pub(crate) fn load_config() -> Result<Option<InstallConfiguration>> {
241    let env = EnvProperties {
242        sys_arch: std::env::consts::ARCH.to_string(),
243    };
244    const SYSTEMD_CONVENTIONAL_BASES: &[&str] = &["/usr/lib", "/usr/local/lib", "/etc", "/run"];
245    let fragments = liboverdrop::scan(SYSTEMD_CONVENTIONAL_BASES, "bootc/install", &["toml"], true);
246    let mut config: Option<InstallConfiguration> = None;
247    for (_name, path) in fragments {
248        let buf = std::fs::read_to_string(&path)?;
249        let mut unused = std::collections::HashSet::new();
250        let de = toml::Deserializer::parse(&buf).with_context(|| format!("Parsing {path:?}"))?;
251        let mut c: InstallConfigurationToplevel = serde_ignored::deserialize(de, |path| {
252            unused.insert(path.to_string());
253        })
254        .with_context(|| format!("Parsing {path:?}"))?;
255        for key in unused {
256            eprintln!("warning: {path:?}: Unknown key {key}");
257        }
258        if let Some(config) = config.as_mut() {
259            if let Some(install) = c.install {
260                tracing::debug!("Merging install config: {install:?}");
261                config.merge(install, &env);
262            }
263        } else {
264            // Only set the config if it matches the current arch
265            // If no arch is specified, set the config unconditionally
266            if let Some(ref mut install) = c.install {
267                if install
268                    .match_architectures
269                    .as_ref()
270                    .map(|a| a.contains(&env.sys_arch))
271                    .unwrap_or(true)
272                {
273                    config = c.install;
274                }
275            }
276        }
277    }
278    if let Some(config) = config.as_mut() {
279        config.canonicalize();
280    }
281    Ok(config)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    /// Verify that we can parse our default config file
290    fn test_parse_config() {
291        let env = EnvProperties {
292            sys_arch: "x86_64".to_string(),
293        };
294        let c: InstallConfigurationToplevel = toml::from_str(
295            r##"[install]
296root-fs-type = "xfs"
297"##,
298        )
299        .unwrap();
300        let mut install = c.install.unwrap();
301        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Xfs);
302        let other = InstallConfigurationToplevel {
303            install: Some(InstallConfiguration {
304                root_fs_type: Some(Filesystem::Ext4),
305                ..Default::default()
306            }),
307        };
308        install.merge(other.install.unwrap(), &env);
309        assert_eq!(
310            install.root_fs_type.as_ref().copied().unwrap(),
311            Filesystem::Ext4
312        );
313        // This one shouldn't have been set
314        assert!(install.filesystem_root().is_none());
315        install.canonicalize();
316        assert_eq!(install.root_fs_type.as_ref().unwrap(), &Filesystem::Ext4);
317        assert_eq!(
318            install.filesystem_root().unwrap().fstype.unwrap(),
319            Filesystem::Ext4
320        );
321
322        let c: InstallConfigurationToplevel = toml::from_str(
323            r##"[install]
324root-fs-type = "ext4"
325kargs = ["console=ttyS0", "foo=bar"]
326"##,
327        )
328        .unwrap();
329        let mut install = c.install.unwrap();
330        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
331        let other = InstallConfigurationToplevel {
332            install: Some(InstallConfiguration {
333                kargs: Some(
334                    ["console=tty0", "nosmt"]
335                        .into_iter()
336                        .map(ToOwned::to_owned)
337                        .collect(),
338                ),
339                ..Default::default()
340            }),
341        };
342        install.merge(other.install.unwrap(), &env);
343        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
344        assert_eq!(
345            install.kargs,
346            Some(
347                ["console=ttyS0", "foo=bar", "console=tty0", "nosmt"]
348                    .into_iter()
349                    .map(ToOwned::to_owned)
350                    .collect()
351            )
352        )
353    }
354
355    #[test]
356    fn test_parse_filesystems() {
357        let env = EnvProperties {
358            sys_arch: "x86_64".to_string(),
359        };
360        let c: InstallConfigurationToplevel = toml::from_str(
361            r##"[install.filesystem.root]
362type = "xfs"
363"##,
364        )
365        .unwrap();
366        let mut install = c.install.unwrap();
367        assert_eq!(
368            install.filesystem_root().unwrap().fstype.unwrap(),
369            Filesystem::Xfs
370        );
371        let other = InstallConfigurationToplevel {
372            install: Some(InstallConfiguration {
373                filesystem: Some(BasicFilesystems {
374                    root: Some(RootFS {
375                        fstype: Some(Filesystem::Ext4),
376                    }),
377                }),
378                ..Default::default()
379            }),
380        };
381        install.merge(other.install.unwrap(), &env);
382        assert_eq!(
383            install.filesystem_root().unwrap().fstype.unwrap(),
384            Filesystem::Ext4
385        );
386    }
387
388    #[test]
389    fn test_parse_block() {
390        let env = EnvProperties {
391            sys_arch: "x86_64".to_string(),
392        };
393        let c: InstallConfigurationToplevel = toml::from_str(
394            r##"[install.filesystem.root]
395type = "xfs"
396"##,
397        )
398        .unwrap();
399        let mut install = c.install.unwrap();
400        // Verify the default (but note canonicalization mutates)
401        {
402            let mut install = install.clone();
403            install.canonicalize();
404            assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Direct);
405        }
406        let other = InstallConfigurationToplevel {
407            install: Some(InstallConfiguration {
408                block: Some(vec![]),
409                ..Default::default()
410            }),
411        };
412        install.merge(other.install.unwrap(), &env);
413        // Should be set, but zero length
414        assert_eq!(install.block.as_ref().unwrap().len(), 0);
415        assert!(install.get_block_setup(None).is_err());
416
417        let c: InstallConfigurationToplevel = toml::from_str(
418            r##"[install]
419block = ["tpm2-luks"]"##,
420        )
421        .unwrap();
422        let mut install = c.install.unwrap();
423        install.canonicalize();
424        assert_eq!(install.block.as_ref().unwrap().len(), 1);
425        assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Tpm2Luks);
426
427        // And verify passing a disallowed config is an error
428        assert!(install.get_block_setup(Some(BlockSetup::Direct)).is_err());
429    }
430
431    #[test]
432    /// Verify that kargs are only applied to supported architectures
433    fn test_arch() {
434        // no arch specified, kargs ensure that kargs are applied unconditionally
435        let env = EnvProperties {
436            sys_arch: "x86_64".to_string(),
437        };
438        let c: InstallConfigurationToplevel = toml::from_str(
439            r##"[install]
440root-fs-type = "xfs"
441"##,
442        )
443        .unwrap();
444        let mut install = c.install.unwrap();
445        let other = InstallConfigurationToplevel {
446            install: Some(InstallConfiguration {
447                kargs: Some(
448                    ["console=tty0", "nosmt"]
449                        .into_iter()
450                        .map(ToOwned::to_owned)
451                        .collect(),
452                ),
453                ..Default::default()
454            }),
455        };
456        install.merge(other.install.unwrap(), &env);
457        assert_eq!(
458            install.kargs,
459            Some(
460                ["console=tty0", "nosmt"]
461                    .into_iter()
462                    .map(ToOwned::to_owned)
463                    .collect()
464            )
465        );
466        let env = EnvProperties {
467            sys_arch: "aarch64".to_string(),
468        };
469        let c: InstallConfigurationToplevel = toml::from_str(
470            r##"[install]
471root-fs-type = "xfs"
472"##,
473        )
474        .unwrap();
475        let mut install = c.install.unwrap();
476        let other = InstallConfigurationToplevel {
477            install: Some(InstallConfiguration {
478                kargs: Some(
479                    ["console=tty0", "nosmt"]
480                        .into_iter()
481                        .map(ToOwned::to_owned)
482                        .collect(),
483                ),
484                ..Default::default()
485            }),
486        };
487        install.merge(other.install.unwrap(), &env);
488        assert_eq!(
489            install.kargs,
490            Some(
491                ["console=tty0", "nosmt"]
492                    .into_iter()
493                    .map(ToOwned::to_owned)
494                    .collect()
495            )
496        );
497
498        // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch
499        let env = EnvProperties {
500            sys_arch: "aarch64".to_string(),
501        };
502        let c: InstallConfigurationToplevel = toml::from_str(
503            r##"[install]
504root-fs-type = "xfs"
505"##,
506        )
507        .unwrap();
508        let mut install = c.install.unwrap();
509        let other = InstallConfigurationToplevel {
510            install: Some(InstallConfiguration {
511                kargs: Some(
512                    ["console=ttyS0", "foo=bar"]
513                        .into_iter()
514                        .map(ToOwned::to_owned)
515                        .collect(),
516                ),
517                match_architectures: Some(["x86_64"].into_iter().map(ToOwned::to_owned).collect()),
518                ..Default::default()
519            }),
520        };
521        install.merge(other.install.unwrap(), &env);
522        assert_eq!(install.kargs, None);
523        let other = InstallConfigurationToplevel {
524            install: Some(InstallConfiguration {
525                kargs: Some(
526                    ["console=tty0", "nosmt"]
527                        .into_iter()
528                        .map(ToOwned::to_owned)
529                        .collect(),
530                ),
531                match_architectures: Some(["aarch64"].into_iter().map(ToOwned::to_owned).collect()),
532                ..Default::default()
533            }),
534        };
535        install.merge(other.install.unwrap(), &env);
536        assert_eq!(
537            install.kargs,
538            Some(
539                ["console=tty0", "nosmt"]
540                    .into_iter()
541                    .map(ToOwned::to_owned)
542                    .collect()
543            )
544        );
545
546        // multiple arch specified, ensure that kargs are applied to both archs
547        let env = EnvProperties {
548            sys_arch: "x86_64".to_string(),
549        };
550        let c: InstallConfigurationToplevel = toml::from_str(
551            r##"[install]
552root-fs-type = "xfs"
553"##,
554        )
555        .unwrap();
556        let mut install = c.install.unwrap();
557        let other = InstallConfigurationToplevel {
558            install: Some(InstallConfiguration {
559                kargs: Some(
560                    ["console=tty0", "nosmt"]
561                        .into_iter()
562                        .map(ToOwned::to_owned)
563                        .collect(),
564                ),
565                match_architectures: Some(
566                    ["x86_64", "aarch64"]
567                        .into_iter()
568                        .map(ToOwned::to_owned)
569                        .collect(),
570                ),
571                ..Default::default()
572            }),
573        };
574        install.merge(other.install.unwrap(), &env);
575        assert_eq!(
576            install.kargs,
577            Some(
578                ["console=tty0", "nosmt"]
579                    .into_iter()
580                    .map(ToOwned::to_owned)
581                    .collect()
582            )
583        );
584        let env = EnvProperties {
585            sys_arch: "aarch64".to_string(),
586        };
587        let c: InstallConfigurationToplevel = toml::from_str(
588            r##"[install]
589root-fs-type = "xfs"
590"##,
591        )
592        .unwrap();
593        let mut install = c.install.unwrap();
594        let other = InstallConfigurationToplevel {
595            install: Some(InstallConfiguration {
596                kargs: Some(
597                    ["console=tty0", "nosmt"]
598                        .into_iter()
599                        .map(ToOwned::to_owned)
600                        .collect(),
601                ),
602                match_architectures: Some(
603                    ["x86_64", "aarch64"]
604                        .into_iter()
605                        .map(ToOwned::to_owned)
606                        .collect(),
607                ),
608                ..Default::default()
609            }),
610        };
611        install.merge(other.install.unwrap(), &env);
612        assert_eq!(
613            install.kargs,
614            Some(
615                ["console=tty0", "nosmt"]
616                    .into_iter()
617                    .map(ToOwned::to_owned)
618                    .collect()
619            )
620        );
621    }
622
623    #[test]
624    fn test_parse_ostree() {
625        let env = EnvProperties {
626            sys_arch: "x86_64".to_string(),
627        };
628
629        // Table-driven test cases for parsing bls-append-except-default
630        let parse_cases = [
631            ("console=ttyS0", "console=ttyS0"),
632            ("console=ttyS0,115200n8", "console=ttyS0,115200n8"),
633            ("rd.lvm.lv=vg/root", "rd.lvm.lv=vg/root"),
634        ];
635        for (input, expected) in parse_cases {
636            let toml_str = format!(
637                r#"[install.ostree]
638bls-append-except-default = "{input}"
639"#
640            );
641            let c: InstallConfigurationToplevel = toml::from_str(&toml_str).unwrap();
642            assert_eq!(
643                c.install
644                    .unwrap()
645                    .ostree
646                    .unwrap()
647                    .bls_append_except_default
648                    .unwrap(),
649                expected
650            );
651        }
652
653        // Test merging: other config should override original
654        let mut install: InstallConfiguration = toml::from_str(
655            r#"[ostree]
656bls-append-except-default = "console=ttyS0"
657"#,
658        )
659        .unwrap();
660        let other = InstallConfiguration {
661            ostree: Some(OstreeRepoOpts {
662                bls_append_except_default: Some("console=tty0".to_string()),
663                ..Default::default()
664            }),
665            ..Default::default()
666        };
667        install.merge(other, &env);
668        assert_eq!(
669            install.ostree.unwrap().bls_append_except_default.unwrap(),
670            "console=tty0"
671        );
672    }
673
674    #[test]
675    fn test_parse_stateroot() {
676        let c: InstallConfigurationToplevel = toml::from_str(
677            r#"[install]
678stateroot = "custom"
679"#,
680        )
681        .unwrap();
682        assert_eq!(c.install.unwrap().stateroot.unwrap(), "custom");
683    }
684
685    #[test]
686    fn test_merge_stateroot() {
687        let env = EnvProperties {
688            sys_arch: "x86_64".to_string(),
689        };
690        let mut install: InstallConfiguration = toml::from_str(
691            r#"stateroot = "original"
692"#,
693        )
694        .unwrap();
695        let other = InstallConfiguration {
696            stateroot: Some("newroot".to_string()),
697            ..Default::default()
698        };
699        install.merge(other, &env);
700        assert_eq!(install.stateroot.unwrap(), "newroot");
701    }
702
703    #[test]
704    fn test_parse_mount_specs() {
705        let c: InstallConfigurationToplevel = toml::from_str(
706            r#"[install]
707root-mount-spec = "LABEL=rootfs"
708boot-mount-spec = "UUID=abcd-1234"
709"#,
710        )
711        .unwrap();
712        let install = c.install.unwrap();
713        assert_eq!(install.root_mount_spec.unwrap(), "LABEL=rootfs");
714        assert_eq!(install.boot_mount_spec.unwrap(), "UUID=abcd-1234");
715    }
716
717    #[test]
718    fn test_merge_mount_specs() {
719        let env = EnvProperties {
720            sys_arch: "x86_64".to_string(),
721        };
722        let mut install: InstallConfiguration = toml::from_str(
723            r#"root-mount-spec = "UUID=old"
724boot-mount-spec = "UUID=oldboot"
725"#,
726        )
727        .unwrap();
728        let other = InstallConfiguration {
729            root_mount_spec: Some("LABEL=newroot".to_string()),
730            ..Default::default()
731        };
732        install.merge(other, &env);
733        // root_mount_spec should be overridden
734        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "LABEL=newroot");
735        // boot_mount_spec should remain unchanged
736        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "UUID=oldboot");
737    }
738
739    /// Empty mount specs are valid and signal to omit mount kargs entirely.
740    /// See https://github.com/bootc-dev/bootc/issues/1441
741    #[test]
742    fn test_parse_empty_mount_specs() {
743        let c: InstallConfigurationToplevel = toml::from_str(
744            r#"[install]
745root-mount-spec = ""
746boot-mount-spec = ""
747"#,
748        )
749        .unwrap();
750        let install = c.install.unwrap();
751        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "");
752        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "");
753    }
754
755    #[test]
756    fn test_parse_bootupd_skip_boot_uuid() {
757        // Test parsing true
758        let c: InstallConfigurationToplevel = toml::from_str(
759            r#"[install.bootupd]
760skip-boot-uuid = true
761"#,
762        )
763        .unwrap();
764        assert_eq!(
765            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
766            true
767        );
768
769        // Test parsing false
770        let c: InstallConfigurationToplevel = toml::from_str(
771            r#"[install.bootupd]
772skip-boot-uuid = false
773"#,
774        )
775        .unwrap();
776        assert_eq!(
777            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
778            false
779        );
780
781        // Test default (not specified) is None
782        let c: InstallConfigurationToplevel = toml::from_str(
783            r#"[install]
784root-fs-type = "xfs"
785"#,
786        )
787        .unwrap();
788        assert!(c.install.unwrap().bootupd.is_none());
789    }
790
791    #[test]
792    fn test_merge_bootupd_skip_boot_uuid() {
793        let env = EnvProperties {
794            sys_arch: "x86_64".to_string(),
795        };
796        let mut install: InstallConfiguration = toml::from_str(
797            r#"[bootupd]
798skip-boot-uuid = false
799"#,
800        )
801        .unwrap();
802        let other = InstallConfiguration {
803            bootupd: Some(Bootupd {
804                skip_boot_uuid: Some(true),
805            }),
806            ..Default::default()
807        };
808        install.merge(other, &env);
809        // skip_boot_uuid should be overridden to true
810        assert_eq!(install.bootupd.unwrap().skip_boot_uuid.unwrap(), true);
811    }
812}