bootc_lib/install/
config.rs

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