Skip to main content

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    /// Use the Discoverable Partitions Specification for root partition
126    /// discovery.  When true, the `root=` kernel argument is omitted
127    /// and `systemd-gpt-auto-generator` discovers root via its DPS
128    /// type GUID.  Requires the bootloader to implement the Boot Loader
129    /// Interface (systemd-boot always does, GRUB needs the `bli` module).
130    /// Defaults to false for broad compatibility.
131    pub(crate) discoverable_partitions: Option<bool>,
132    /// Enforce that the containers-storage stack has a non-default
133    /// (i.e. not `insecureAcceptAnything`) container image signature policy.
134    pub(crate) enforce_container_sigpolicy: Option<bool>,
135}
136
137fn merge_basic<T>(s: &mut Option<T>, o: Option<T>, _env: &EnvProperties) {
138    if let Some(o) = o {
139        *s = Some(o);
140    }
141}
142
143trait Mergeable {
144    fn merge(&mut self, other: Self, env: &EnvProperties)
145    where
146        Self: Sized;
147}
148
149impl<T> Mergeable for Option<T>
150where
151    T: Mergeable,
152{
153    fn merge(&mut self, other: Self, env: &EnvProperties)
154    where
155        Self: Sized,
156    {
157        if let Some(other) = other {
158            if let Some(s) = self.as_mut() {
159                s.merge(other, env)
160            } else {
161                *self = Some(other);
162            }
163        }
164    }
165}
166
167impl Mergeable for RootFS {
168    /// Apply any values in other, overriding any existing values in `self`.
169    fn merge(&mut self, other: Self, env: &EnvProperties) {
170        merge_basic(&mut self.fstype, other.fstype, env)
171    }
172}
173
174impl Mergeable for BasicFilesystems {
175    /// Apply any values in other, overriding any existing values in `self`.
176    fn merge(&mut self, other: Self, env: &EnvProperties) {
177        self.root.merge(other.root, env)
178    }
179}
180
181impl Mergeable for OstreeRepoOpts {
182    /// Apply any values in other, overriding any existing values in `self`.
183    fn merge(&mut self, other: Self, env: &EnvProperties) {
184        merge_basic(
185            &mut self.bls_append_except_default,
186            other.bls_append_except_default,
187            env,
188        )
189    }
190}
191
192impl Mergeable for Bootupd {
193    /// Apply any values in other, overriding any existing values in `self`.
194    fn merge(&mut self, other: Self, env: &EnvProperties) {
195        merge_basic(&mut self.skip_boot_uuid, other.skip_boot_uuid, env)
196    }
197}
198
199impl Mergeable for InstallConfiguration {
200    /// Apply any values in other, overriding any existing values in `self`.
201    fn merge(&mut self, other: Self, env: &EnvProperties) {
202        // if arch is specified, only merge config if it matches the current arch
203        // if arch is not specified, merge config unconditionally
204        if other
205            .match_architectures
206            .map(|a| a.contains(&env.sys_arch))
207            .unwrap_or(true)
208        {
209            merge_basic(&mut self.root_fs_type, other.root_fs_type, env);
210            #[cfg(feature = "install-to-disk")]
211            merge_basic(&mut self.block, other.block, env);
212            self.filesystem.merge(other.filesystem, env);
213            self.ostree.merge(other.ostree, env);
214            merge_basic(&mut self.stateroot, other.stateroot, env);
215            merge_basic(&mut self.root_mount_spec, other.root_mount_spec, env);
216            merge_basic(&mut self.boot_mount_spec, other.boot_mount_spec, env);
217            self.bootupd.merge(other.bootupd, env);
218            merge_basic(&mut self.bootloader, other.bootloader, env);
219            merge_basic(
220                &mut self.discoverable_partitions,
221                other.discoverable_partitions,
222                env,
223            );
224            merge_basic(
225                &mut self.enforce_container_sigpolicy,
226                other.enforce_container_sigpolicy,
227                env,
228            );
229            if let Some(other_kargs) = other.kargs {
230                self.kargs
231                    .get_or_insert_with(Default::default)
232                    .extend(other_kargs)
233            }
234            if let Some(other_karg_deletes) = other.karg_deletes {
235                self.karg_deletes
236                    .get_or_insert_with(Default::default)
237                    .extend(other_karg_deletes)
238            }
239        }
240    }
241}
242
243impl InstallConfiguration {
244    /// Set defaults (e.g. `block`), and also handle fields that can be specified multiple ways
245    /// by synchronizing the values of the fields to ensure they're the same.
246    ///
247    /// - install.root-fs-type is synchronized with install.filesystems.root.type; if
248    ///   both are set, then the latter takes precedence
249    pub(crate) fn canonicalize(&mut self) {
250        // New canonical form wins.
251        if let Some(rootfs_type) = self.filesystem_root().and_then(|f| f.fstype.as_ref()) {
252            self.root_fs_type = Some(*rootfs_type)
253        } else if let Some(rootfs) = self.root_fs_type.as_ref() {
254            let fs = self.filesystem.get_or_insert_with(Default::default);
255            let root = fs.root.get_or_insert_with(Default::default);
256            root.fstype = Some(*rootfs);
257        }
258
259        #[cfg(feature = "install-to-disk")]
260        if self.block.is_none() {
261            self.block = Some(vec![BlockSetup::Direct]);
262        }
263    }
264
265    /// Convenience helper to access the root filesystem
266    pub(crate) fn filesystem_root(&self) -> Option<&RootFS> {
267        self.filesystem.as_ref().and_then(|fs| fs.root.as_ref())
268    }
269
270    // Remove all configuration which is handled by `install to-filesystem`.
271    pub(crate) fn filter_to_external(&mut self) {
272        self.kargs.take();
273        self.karg_deletes.take();
274    }
275
276    #[cfg(feature = "install-to-disk")]
277    pub(crate) fn get_block_setup(&self, default: Option<BlockSetup>) -> Result<BlockSetup> {
278        let valid_block_setups = self.block.as_deref().unwrap_or_default();
279        let default_block = valid_block_setups.iter().next().ok_or_else(|| {
280            anyhow::anyhow!("Empty block storage configuration in install configuration")
281        })?;
282        let block_setup = default.as_ref().unwrap_or(default_block);
283        if !valid_block_setups.contains(block_setup) {
284            anyhow::bail!("Block setup {block_setup:?} is not enabled in installation config");
285        }
286        Ok(*block_setup)
287    }
288}
289
290#[context("Loading configuration")]
291/// Load the install configuration, merging all found configuration files.
292pub(crate) fn load_config() -> Result<Option<InstallConfiguration>> {
293    let env = EnvProperties {
294        sys_arch: std::env::consts::ARCH.to_string(),
295    };
296    const SYSTEMD_CONVENTIONAL_BASES: &[&str] = &["/usr/lib", "/usr/local/lib", "/etc", "/run"];
297    let fragments = liboverdrop::scan(SYSTEMD_CONVENTIONAL_BASES, "bootc/install", &["toml"], true);
298    let mut config: Option<InstallConfiguration> = None;
299    for (_name, path) in fragments {
300        let buf = std::fs::read_to_string(&path)?;
301        let mut unused = std::collections::HashSet::new();
302        let de = toml::Deserializer::parse(&buf).with_context(|| format!("Parsing {path:?}"))?;
303        let mut c: InstallConfigurationToplevel = serde_ignored::deserialize(de, |path| {
304            unused.insert(path.to_string());
305        })
306        .with_context(|| format!("Parsing {path:?}"))?;
307        for key in unused {
308            eprintln!("warning: {path:?}: Unknown key {key}");
309        }
310        if let Some(config) = config.as_mut() {
311            if let Some(install) = c.install {
312                tracing::debug!("Merging install config: {install:?}");
313                config.merge(install, &env);
314            }
315        } else {
316            // Only set the config if it matches the current arch
317            // If no arch is specified, set the config unconditionally
318            if let Some(ref mut install) = c.install {
319                if install
320                    .match_architectures
321                    .as_ref()
322                    .map(|a| a.contains(&env.sys_arch))
323                    .unwrap_or(true)
324                {
325                    config = c.install;
326                }
327            }
328        }
329    }
330    if let Some(config) = config.as_mut() {
331        config.canonicalize();
332    }
333    Ok(config)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    /// Verify that we can parse our default config file
342    fn test_parse_config() {
343        let env = EnvProperties {
344            sys_arch: "x86_64".to_string(),
345        };
346        let c: InstallConfigurationToplevel = toml::from_str(
347            r##"[install]
348root-fs-type = "xfs"
349"##,
350        )
351        .unwrap();
352        let mut install = c.install.unwrap();
353        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Xfs);
354        let other = InstallConfigurationToplevel {
355            install: Some(InstallConfiguration {
356                root_fs_type: Some(Filesystem::Ext4),
357                ..Default::default()
358            }),
359        };
360        install.merge(other.install.unwrap(), &env);
361        assert_eq!(
362            install.root_fs_type.as_ref().copied().unwrap(),
363            Filesystem::Ext4
364        );
365        // This one shouldn't have been set
366        assert!(install.filesystem_root().is_none());
367        install.canonicalize();
368        assert_eq!(install.root_fs_type.as_ref().unwrap(), &Filesystem::Ext4);
369        assert_eq!(
370            install.filesystem_root().unwrap().fstype.unwrap(),
371            Filesystem::Ext4
372        );
373
374        let c: InstallConfigurationToplevel = toml::from_str(
375            r##"[install]
376root-fs-type = "ext4"
377kargs = ["console=ttyS0", "foo=bar"]
378karg-deletes = ["debug", "bar=baz"]
379"##,
380        )
381        .unwrap();
382        let mut install = c.install.unwrap();
383        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
384        let other = InstallConfigurationToplevel {
385            install: Some(InstallConfiguration {
386                kargs: Some(
387                    ["console=tty0", "nosmt"]
388                        .into_iter()
389                        .map(ToOwned::to_owned)
390                        .collect(),
391                ),
392                karg_deletes: Some(
393                    ["baz", "bar=baz"]
394                        .into_iter()
395                        .map(ToOwned::to_owned)
396                        .collect(),
397                ),
398                ..Default::default()
399            }),
400        };
401        install.merge(other.install.unwrap(), &env);
402        assert_eq!(install.root_fs_type.unwrap(), Filesystem::Ext4);
403        assert_eq!(
404            install.kargs,
405            Some(
406                ["console=ttyS0", "foo=bar", "console=tty0", "nosmt"]
407                    .into_iter()
408                    .map(ToOwned::to_owned)
409                    .collect()
410            )
411        );
412        assert_eq!(
413            install.karg_deletes,
414            Some(
415                ["debug", "bar=baz", "baz", "bar=baz"]
416                    .into_iter()
417                    .map(ToOwned::to_owned)
418                    .collect()
419            )
420        );
421    }
422
423    #[test]
424    fn test_parse_filesystems() {
425        let env = EnvProperties {
426            sys_arch: "x86_64".to_string(),
427        };
428        let c: InstallConfigurationToplevel = toml::from_str(
429            r##"[install.filesystem.root]
430type = "xfs"
431"##,
432        )
433        .unwrap();
434        let mut install = c.install.unwrap();
435        assert_eq!(
436            install.filesystem_root().unwrap().fstype.unwrap(),
437            Filesystem::Xfs
438        );
439        let other = InstallConfigurationToplevel {
440            install: Some(InstallConfiguration {
441                filesystem: Some(BasicFilesystems {
442                    root: Some(RootFS {
443                        fstype: Some(Filesystem::Ext4),
444                    }),
445                }),
446                ..Default::default()
447            }),
448        };
449        install.merge(other.install.unwrap(), &env);
450        assert_eq!(
451            install.filesystem_root().unwrap().fstype.unwrap(),
452            Filesystem::Ext4
453        );
454    }
455
456    #[test]
457    #[cfg(feature = "install-to-disk")]
458    fn test_parse_block() {
459        let env = EnvProperties {
460            sys_arch: "x86_64".to_string(),
461        };
462        let c: InstallConfigurationToplevel = toml::from_str(
463            r##"[install.filesystem.root]
464type = "xfs"
465"##,
466        )
467        .unwrap();
468        let mut install = c.install.unwrap();
469        // Verify the default (but note canonicalization mutates)
470        {
471            let mut install = install.clone();
472            install.canonicalize();
473            assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Direct);
474        }
475        let other = InstallConfigurationToplevel {
476            install: Some(InstallConfiguration {
477                block: Some(vec![]),
478                ..Default::default()
479            }),
480        };
481        install.merge(other.install.unwrap(), &env);
482        // Should be set, but zero length
483        assert_eq!(install.block.as_ref().unwrap().len(), 0);
484        assert!(install.get_block_setup(None).is_err());
485
486        let c: InstallConfigurationToplevel = toml::from_str(
487            r##"[install]
488block = ["tpm2-luks"]"##,
489        )
490        .unwrap();
491        let mut install = c.install.unwrap();
492        install.canonicalize();
493        assert_eq!(install.block.as_ref().unwrap().len(), 1);
494        assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Tpm2Luks);
495
496        // And verify passing a disallowed config is an error
497        assert!(install.get_block_setup(Some(BlockSetup::Direct)).is_err());
498    }
499
500    #[test]
501    /// Verify that kargs are only applied to supported architectures
502    fn test_arch() {
503        // no arch specified, kargs ensure that kargs are applied unconditionally
504        let env = EnvProperties {
505            sys_arch: "x86_64".to_string(),
506        };
507        let c: InstallConfigurationToplevel = toml::from_str(
508            r##"[install]
509root-fs-type = "xfs"
510"##,
511        )
512        .unwrap();
513        let mut install = c.install.unwrap();
514        let other = InstallConfigurationToplevel {
515            install: Some(InstallConfiguration {
516                kargs: Some(
517                    ["console=tty0", "nosmt"]
518                        .into_iter()
519                        .map(ToOwned::to_owned)
520                        .collect(),
521                ),
522                ..Default::default()
523            }),
524        };
525        install.merge(other.install.unwrap(), &env);
526        assert_eq!(
527            install.kargs,
528            Some(
529                ["console=tty0", "nosmt"]
530                    .into_iter()
531                    .map(ToOwned::to_owned)
532                    .collect()
533            )
534        );
535        let env = EnvProperties {
536            sys_arch: "aarch64".to_string(),
537        };
538        let c: InstallConfigurationToplevel = toml::from_str(
539            r##"[install]
540root-fs-type = "xfs"
541"##,
542        )
543        .unwrap();
544        let mut install = c.install.unwrap();
545        let other = InstallConfigurationToplevel {
546            install: Some(InstallConfiguration {
547                kargs: Some(
548                    ["console=tty0", "nosmt"]
549                        .into_iter()
550                        .map(ToOwned::to_owned)
551                        .collect(),
552                ),
553                ..Default::default()
554            }),
555        };
556        install.merge(other.install.unwrap(), &env);
557        assert_eq!(
558            install.kargs,
559            Some(
560                ["console=tty0", "nosmt"]
561                    .into_iter()
562                    .map(ToOwned::to_owned)
563                    .collect()
564            )
565        );
566
567        // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch
568        let env = EnvProperties {
569            sys_arch: "aarch64".to_string(),
570        };
571        let c: InstallConfigurationToplevel = toml::from_str(
572            r##"[install]
573root-fs-type = "xfs"
574"##,
575        )
576        .unwrap();
577        let mut install = c.install.unwrap();
578        let other = InstallConfigurationToplevel {
579            install: Some(InstallConfiguration {
580                kargs: Some(
581                    ["console=ttyS0", "foo=bar"]
582                        .into_iter()
583                        .map(ToOwned::to_owned)
584                        .collect(),
585                ),
586                match_architectures: Some(["x86_64"].into_iter().map(ToOwned::to_owned).collect()),
587                ..Default::default()
588            }),
589        };
590        install.merge(other.install.unwrap(), &env);
591        assert_eq!(install.kargs, None);
592        let other = InstallConfigurationToplevel {
593            install: Some(InstallConfiguration {
594                kargs: Some(
595                    ["console=tty0", "nosmt"]
596                        .into_iter()
597                        .map(ToOwned::to_owned)
598                        .collect(),
599                ),
600                match_architectures: Some(["aarch64"].into_iter().map(ToOwned::to_owned).collect()),
601                ..Default::default()
602            }),
603        };
604        install.merge(other.install.unwrap(), &env);
605        assert_eq!(
606            install.kargs,
607            Some(
608                ["console=tty0", "nosmt"]
609                    .into_iter()
610                    .map(ToOwned::to_owned)
611                    .collect()
612            )
613        );
614
615        // multiple arch specified, ensure that kargs are applied to both archs
616        let env = EnvProperties {
617            sys_arch: "x86_64".to_string(),
618        };
619        let c: InstallConfigurationToplevel = toml::from_str(
620            r##"[install]
621root-fs-type = "xfs"
622"##,
623        )
624        .unwrap();
625        let mut install = c.install.unwrap();
626        let other = InstallConfigurationToplevel {
627            install: Some(InstallConfiguration {
628                kargs: Some(
629                    ["console=tty0", "nosmt"]
630                        .into_iter()
631                        .map(ToOwned::to_owned)
632                        .collect(),
633                ),
634                match_architectures: Some(
635                    ["x86_64", "aarch64"]
636                        .into_iter()
637                        .map(ToOwned::to_owned)
638                        .collect(),
639                ),
640                ..Default::default()
641            }),
642        };
643        install.merge(other.install.unwrap(), &env);
644        assert_eq!(
645            install.kargs,
646            Some(
647                ["console=tty0", "nosmt"]
648                    .into_iter()
649                    .map(ToOwned::to_owned)
650                    .collect()
651            )
652        );
653        let env = EnvProperties {
654            sys_arch: "aarch64".to_string(),
655        };
656        let c: InstallConfigurationToplevel = toml::from_str(
657            r##"[install]
658root-fs-type = "xfs"
659"##,
660        )
661        .unwrap();
662        let mut install = c.install.unwrap();
663        let other = InstallConfigurationToplevel {
664            install: Some(InstallConfiguration {
665                kargs: Some(
666                    ["console=tty0", "nosmt"]
667                        .into_iter()
668                        .map(ToOwned::to_owned)
669                        .collect(),
670                ),
671                match_architectures: Some(
672                    ["x86_64", "aarch64"]
673                        .into_iter()
674                        .map(ToOwned::to_owned)
675                        .collect(),
676                ),
677                ..Default::default()
678            }),
679        };
680        install.merge(other.install.unwrap(), &env);
681        assert_eq!(
682            install.kargs,
683            Some(
684                ["console=tty0", "nosmt"]
685                    .into_iter()
686                    .map(ToOwned::to_owned)
687                    .collect()
688            )
689        );
690    }
691
692    #[test]
693    fn test_parse_ostree() {
694        let env = EnvProperties {
695            sys_arch: "x86_64".to_string(),
696        };
697
698        // Table-driven test cases for parsing bls-append-except-default
699        let parse_cases = [
700            ("console=ttyS0", "console=ttyS0"),
701            ("console=ttyS0,115200n8", "console=ttyS0,115200n8"),
702            ("rd.lvm.lv=vg/root", "rd.lvm.lv=vg/root"),
703        ];
704        for (input, expected) in parse_cases {
705            let toml_str = format!(
706                r#"[install.ostree]
707bls-append-except-default = "{input}"
708"#
709            );
710            let c: InstallConfigurationToplevel = toml::from_str(&toml_str).unwrap();
711            assert_eq!(
712                c.install
713                    .unwrap()
714                    .ostree
715                    .unwrap()
716                    .bls_append_except_default
717                    .unwrap(),
718                expected
719            );
720        }
721
722        // Test merging: other config should override original
723        let mut install: InstallConfiguration = toml::from_str(
724            r#"[ostree]
725bls-append-except-default = "console=ttyS0"
726"#,
727        )
728        .unwrap();
729        let other = InstallConfiguration {
730            ostree: Some(OstreeRepoOpts {
731                bls_append_except_default: Some("console=tty0".to_string()),
732                ..Default::default()
733            }),
734            ..Default::default()
735        };
736        install.merge(other, &env);
737        assert_eq!(
738            install.ostree.unwrap().bls_append_except_default.unwrap(),
739            "console=tty0"
740        );
741    }
742
743    #[test]
744    fn test_parse_stateroot() {
745        let c: InstallConfigurationToplevel = toml::from_str(
746            r#"[install]
747stateroot = "custom"
748"#,
749        )
750        .unwrap();
751        assert_eq!(c.install.unwrap().stateroot.unwrap(), "custom");
752    }
753
754    #[test]
755    fn test_merge_stateroot() {
756        let env = EnvProperties {
757            sys_arch: "x86_64".to_string(),
758        };
759        let mut install: InstallConfiguration = toml::from_str(
760            r#"stateroot = "original"
761"#,
762        )
763        .unwrap();
764        let other = InstallConfiguration {
765            stateroot: Some("newroot".to_string()),
766            ..Default::default()
767        };
768        install.merge(other, &env);
769        assert_eq!(install.stateroot.unwrap(), "newroot");
770    }
771
772    #[test]
773    fn test_parse_mount_specs() {
774        let c: InstallConfigurationToplevel = toml::from_str(
775            r#"[install]
776root-mount-spec = "LABEL=rootfs"
777boot-mount-spec = "UUID=abcd-1234"
778"#,
779        )
780        .unwrap();
781        let install = c.install.unwrap();
782        assert_eq!(install.root_mount_spec.unwrap(), "LABEL=rootfs");
783        assert_eq!(install.boot_mount_spec.unwrap(), "UUID=abcd-1234");
784    }
785
786    #[test]
787    fn test_merge_mount_specs() {
788        let env = EnvProperties {
789            sys_arch: "x86_64".to_string(),
790        };
791        let mut install: InstallConfiguration = toml::from_str(
792            r#"root-mount-spec = "UUID=old"
793boot-mount-spec = "UUID=oldboot"
794"#,
795        )
796        .unwrap();
797        let other = InstallConfiguration {
798            root_mount_spec: Some("LABEL=newroot".to_string()),
799            ..Default::default()
800        };
801        install.merge(other, &env);
802        // root_mount_spec should be overridden
803        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "LABEL=newroot");
804        // boot_mount_spec should remain unchanged
805        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "UUID=oldboot");
806    }
807
808    /// Empty mount specs are valid and signal to omit mount kargs entirely.
809    /// See https://github.com/bootc-dev/bootc/issues/1441
810    #[test]
811    fn test_parse_empty_mount_specs() {
812        let c: InstallConfigurationToplevel = toml::from_str(
813            r#"[install]
814root-mount-spec = ""
815boot-mount-spec = ""
816"#,
817        )
818        .unwrap();
819        let install = c.install.unwrap();
820        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "");
821        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "");
822    }
823
824    #[test]
825    fn test_parse_bootupd_skip_boot_uuid() {
826        // Test parsing true
827        let c: InstallConfigurationToplevel = toml::from_str(
828            r#"[install.bootupd]
829skip-boot-uuid = true
830"#,
831        )
832        .unwrap();
833        assert_eq!(
834            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
835            true
836        );
837
838        // Test parsing false
839        let c: InstallConfigurationToplevel = toml::from_str(
840            r#"[install.bootupd]
841skip-boot-uuid = false
842"#,
843        )
844        .unwrap();
845        assert_eq!(
846            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
847            false
848        );
849
850        // Test default (not specified) is None
851        let c: InstallConfigurationToplevel = toml::from_str(
852            r#"[install]
853root-fs-type = "xfs"
854"#,
855        )
856        .unwrap();
857        assert!(c.install.unwrap().bootupd.is_none());
858    }
859
860    #[test]
861    fn test_merge_bootupd_skip_boot_uuid() {
862        let env = EnvProperties {
863            sys_arch: "x86_64".to_string(),
864        };
865        let mut install: InstallConfiguration = toml::from_str(
866            r#"[bootupd]
867skip-boot-uuid = false
868"#,
869        )
870        .unwrap();
871        let other = InstallConfiguration {
872            bootupd: Some(Bootupd {
873                skip_boot_uuid: Some(true),
874            }),
875            ..Default::default()
876        };
877        install.merge(other, &env);
878        // skip_boot_uuid should be overridden to true
879        assert_eq!(install.bootupd.unwrap().skip_boot_uuid.unwrap(), true);
880    }
881}
882
883#[test]
884fn test_parse_bootloader() {
885    let env = EnvProperties {
886        sys_arch: "x86_64".to_string(),
887    };
888
889    // 1. Test parsing "none"
890    let c: InstallConfigurationToplevel = toml::from_str(
891        r##"[install]
892bootloader = "none"
893"##,
894    )
895    .unwrap();
896    assert_eq!(c.install.unwrap().bootloader, Some(Bootloader::None));
897
898    // 2. Test parsing "grub"
899    let c: InstallConfigurationToplevel = toml::from_str(
900        r##"[install]
901bootloader = "grub"
902"##,
903    )
904    .unwrap();
905    assert_eq!(c.install.unwrap().bootloader, Some(Bootloader::Grub));
906
907    // 3. Test merging
908    // Initial config has "systemd"
909    let mut install: InstallConfiguration = toml::from_str(
910        r#"bootloader = "systemd"
911"#,
912    )
913    .unwrap();
914
915    // Incoming config has "none"
916    let other = InstallConfiguration {
917        bootloader: Some(Bootloader::None),
918        ..Default::default()
919    };
920
921    // Merge should overwrite systemd with none
922    install.merge(other, &env);
923    assert_eq!(install.bootloader, Some(Bootloader::None));
924}
925
926#[test]
927fn test_parse_discoverable_partitions() {
928    let c: InstallConfigurationToplevel = toml::from_str(
929        r##"[install]
930discoverable-partitions = true
931"##,
932    )
933    .unwrap();
934    assert_eq!(c.install.unwrap().discoverable_partitions, Some(true));
935
936    let c: InstallConfigurationToplevel = toml::from_str(
937        r##"[install]
938discoverable-partitions = false
939"##,
940    )
941    .unwrap();
942    assert_eq!(c.install.unwrap().discoverable_partitions, Some(false));
943
944    let c: InstallConfigurationToplevel = toml::from_str(
945        r##"[install]
946root-fs-type = "xfs"
947"##,
948    )
949    .unwrap();
950    assert_eq!(c.install.unwrap().discoverable_partitions, None);
951}
952
953#[test]
954fn test_parse_enforce_container_sigpolicy() {
955    let env = EnvProperties {
956        sys_arch: "x86_64".to_string(),
957    };
958
959    // Test parsing true and false
960    for (input, expected) in [("true", true), ("false", false)] {
961        let toml_str = format!(
962            r#"[install]
963enforce-container-sigpolicy = {input}
964"#
965        );
966        let c: InstallConfigurationToplevel = toml::from_str(&toml_str).unwrap();
967        assert_eq!(
968            c.install.unwrap().enforce_container_sigpolicy.unwrap(),
969            expected
970        );
971    }
972
973    // Default (not specified) is None
974    let c: InstallConfigurationToplevel = toml::from_str(
975        r#"[install]
976root-fs-type = "xfs"
977"#,
978    )
979    .unwrap();
980    assert!(c.install.unwrap().enforce_container_sigpolicy.is_none());
981
982    // Test merging: last write wins
983    let mut install: InstallConfiguration = toml::from_str(
984        r#"enforce-container-sigpolicy = false
985"#,
986    )
987    .unwrap();
988    let other = InstallConfiguration {
989        enforce_container_sigpolicy: Some(true),
990        ..Default::default()
991    };
992    install.merge(other, &env);
993    assert_eq!(install.enforce_container_sigpolicy.unwrap(), true);
994}