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    fn test_parse_block() {
458        let env = EnvProperties {
459            sys_arch: "x86_64".to_string(),
460        };
461        let c: InstallConfigurationToplevel = toml::from_str(
462            r##"[install.filesystem.root]
463type = "xfs"
464"##,
465        )
466        .unwrap();
467        let mut install = c.install.unwrap();
468        // Verify the default (but note canonicalization mutates)
469        {
470            let mut install = install.clone();
471            install.canonicalize();
472            assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Direct);
473        }
474        let other = InstallConfigurationToplevel {
475            install: Some(InstallConfiguration {
476                block: Some(vec![]),
477                ..Default::default()
478            }),
479        };
480        install.merge(other.install.unwrap(), &env);
481        // Should be set, but zero length
482        assert_eq!(install.block.as_ref().unwrap().len(), 0);
483        assert!(install.get_block_setup(None).is_err());
484
485        let c: InstallConfigurationToplevel = toml::from_str(
486            r##"[install]
487block = ["tpm2-luks"]"##,
488        )
489        .unwrap();
490        let mut install = c.install.unwrap();
491        install.canonicalize();
492        assert_eq!(install.block.as_ref().unwrap().len(), 1);
493        assert_eq!(install.get_block_setup(None).unwrap(), BlockSetup::Tpm2Luks);
494
495        // And verify passing a disallowed config is an error
496        assert!(install.get_block_setup(Some(BlockSetup::Direct)).is_err());
497    }
498
499    #[test]
500    /// Verify that kargs are only applied to supported architectures
501    fn test_arch() {
502        // no arch specified, kargs ensure that kargs are applied unconditionally
503        let env = EnvProperties {
504            sys_arch: "x86_64".to_string(),
505        };
506        let c: InstallConfigurationToplevel = toml::from_str(
507            r##"[install]
508root-fs-type = "xfs"
509"##,
510        )
511        .unwrap();
512        let mut install = c.install.unwrap();
513        let other = InstallConfigurationToplevel {
514            install: Some(InstallConfiguration {
515                kargs: Some(
516                    ["console=tty0", "nosmt"]
517                        .into_iter()
518                        .map(ToOwned::to_owned)
519                        .collect(),
520                ),
521                ..Default::default()
522            }),
523        };
524        install.merge(other.install.unwrap(), &env);
525        assert_eq!(
526            install.kargs,
527            Some(
528                ["console=tty0", "nosmt"]
529                    .into_iter()
530                    .map(ToOwned::to_owned)
531                    .collect()
532            )
533        );
534        let env = EnvProperties {
535            sys_arch: "aarch64".to_string(),
536        };
537        let c: InstallConfigurationToplevel = toml::from_str(
538            r##"[install]
539root-fs-type = "xfs"
540"##,
541        )
542        .unwrap();
543        let mut install = c.install.unwrap();
544        let other = InstallConfigurationToplevel {
545            install: Some(InstallConfiguration {
546                kargs: Some(
547                    ["console=tty0", "nosmt"]
548                        .into_iter()
549                        .map(ToOwned::to_owned)
550                        .collect(),
551                ),
552                ..Default::default()
553            }),
554        };
555        install.merge(other.install.unwrap(), &env);
556        assert_eq!(
557            install.kargs,
558            Some(
559                ["console=tty0", "nosmt"]
560                    .into_iter()
561                    .map(ToOwned::to_owned)
562                    .collect()
563            )
564        );
565
566        // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch
567        let env = EnvProperties {
568            sys_arch: "aarch64".to_string(),
569        };
570        let c: InstallConfigurationToplevel = toml::from_str(
571            r##"[install]
572root-fs-type = "xfs"
573"##,
574        )
575        .unwrap();
576        let mut install = c.install.unwrap();
577        let other = InstallConfigurationToplevel {
578            install: Some(InstallConfiguration {
579                kargs: Some(
580                    ["console=ttyS0", "foo=bar"]
581                        .into_iter()
582                        .map(ToOwned::to_owned)
583                        .collect(),
584                ),
585                match_architectures: Some(["x86_64"].into_iter().map(ToOwned::to_owned).collect()),
586                ..Default::default()
587            }),
588        };
589        install.merge(other.install.unwrap(), &env);
590        assert_eq!(install.kargs, None);
591        let other = InstallConfigurationToplevel {
592            install: Some(InstallConfiguration {
593                kargs: Some(
594                    ["console=tty0", "nosmt"]
595                        .into_iter()
596                        .map(ToOwned::to_owned)
597                        .collect(),
598                ),
599                match_architectures: Some(["aarch64"].into_iter().map(ToOwned::to_owned).collect()),
600                ..Default::default()
601            }),
602        };
603        install.merge(other.install.unwrap(), &env);
604        assert_eq!(
605            install.kargs,
606            Some(
607                ["console=tty0", "nosmt"]
608                    .into_iter()
609                    .map(ToOwned::to_owned)
610                    .collect()
611            )
612        );
613
614        // multiple arch specified, ensure that kargs are applied to both archs
615        let env = EnvProperties {
616            sys_arch: "x86_64".to_string(),
617        };
618        let c: InstallConfigurationToplevel = toml::from_str(
619            r##"[install]
620root-fs-type = "xfs"
621"##,
622        )
623        .unwrap();
624        let mut install = c.install.unwrap();
625        let other = InstallConfigurationToplevel {
626            install: Some(InstallConfiguration {
627                kargs: Some(
628                    ["console=tty0", "nosmt"]
629                        .into_iter()
630                        .map(ToOwned::to_owned)
631                        .collect(),
632                ),
633                match_architectures: Some(
634                    ["x86_64", "aarch64"]
635                        .into_iter()
636                        .map(ToOwned::to_owned)
637                        .collect(),
638                ),
639                ..Default::default()
640            }),
641        };
642        install.merge(other.install.unwrap(), &env);
643        assert_eq!(
644            install.kargs,
645            Some(
646                ["console=tty0", "nosmt"]
647                    .into_iter()
648                    .map(ToOwned::to_owned)
649                    .collect()
650            )
651        );
652        let env = EnvProperties {
653            sys_arch: "aarch64".to_string(),
654        };
655        let c: InstallConfigurationToplevel = toml::from_str(
656            r##"[install]
657root-fs-type = "xfs"
658"##,
659        )
660        .unwrap();
661        let mut install = c.install.unwrap();
662        let other = InstallConfigurationToplevel {
663            install: Some(InstallConfiguration {
664                kargs: Some(
665                    ["console=tty0", "nosmt"]
666                        .into_iter()
667                        .map(ToOwned::to_owned)
668                        .collect(),
669                ),
670                match_architectures: Some(
671                    ["x86_64", "aarch64"]
672                        .into_iter()
673                        .map(ToOwned::to_owned)
674                        .collect(),
675                ),
676                ..Default::default()
677            }),
678        };
679        install.merge(other.install.unwrap(), &env);
680        assert_eq!(
681            install.kargs,
682            Some(
683                ["console=tty0", "nosmt"]
684                    .into_iter()
685                    .map(ToOwned::to_owned)
686                    .collect()
687            )
688        );
689    }
690
691    #[test]
692    fn test_parse_ostree() {
693        let env = EnvProperties {
694            sys_arch: "x86_64".to_string(),
695        };
696
697        // Table-driven test cases for parsing bls-append-except-default
698        let parse_cases = [
699            ("console=ttyS0", "console=ttyS0"),
700            ("console=ttyS0,115200n8", "console=ttyS0,115200n8"),
701            ("rd.lvm.lv=vg/root", "rd.lvm.lv=vg/root"),
702        ];
703        for (input, expected) in parse_cases {
704            let toml_str = format!(
705                r#"[install.ostree]
706bls-append-except-default = "{input}"
707"#
708            );
709            let c: InstallConfigurationToplevel = toml::from_str(&toml_str).unwrap();
710            assert_eq!(
711                c.install
712                    .unwrap()
713                    .ostree
714                    .unwrap()
715                    .bls_append_except_default
716                    .unwrap(),
717                expected
718            );
719        }
720
721        // Test merging: other config should override original
722        let mut install: InstallConfiguration = toml::from_str(
723            r#"[ostree]
724bls-append-except-default = "console=ttyS0"
725"#,
726        )
727        .unwrap();
728        let other = InstallConfiguration {
729            ostree: Some(OstreeRepoOpts {
730                bls_append_except_default: Some("console=tty0".to_string()),
731                ..Default::default()
732            }),
733            ..Default::default()
734        };
735        install.merge(other, &env);
736        assert_eq!(
737            install.ostree.unwrap().bls_append_except_default.unwrap(),
738            "console=tty0"
739        );
740    }
741
742    #[test]
743    fn test_parse_stateroot() {
744        let c: InstallConfigurationToplevel = toml::from_str(
745            r#"[install]
746stateroot = "custom"
747"#,
748        )
749        .unwrap();
750        assert_eq!(c.install.unwrap().stateroot.unwrap(), "custom");
751    }
752
753    #[test]
754    fn test_merge_stateroot() {
755        let env = EnvProperties {
756            sys_arch: "x86_64".to_string(),
757        };
758        let mut install: InstallConfiguration = toml::from_str(
759            r#"stateroot = "original"
760"#,
761        )
762        .unwrap();
763        let other = InstallConfiguration {
764            stateroot: Some("newroot".to_string()),
765            ..Default::default()
766        };
767        install.merge(other, &env);
768        assert_eq!(install.stateroot.unwrap(), "newroot");
769    }
770
771    #[test]
772    fn test_parse_mount_specs() {
773        let c: InstallConfigurationToplevel = toml::from_str(
774            r#"[install]
775root-mount-spec = "LABEL=rootfs"
776boot-mount-spec = "UUID=abcd-1234"
777"#,
778        )
779        .unwrap();
780        let install = c.install.unwrap();
781        assert_eq!(install.root_mount_spec.unwrap(), "LABEL=rootfs");
782        assert_eq!(install.boot_mount_spec.unwrap(), "UUID=abcd-1234");
783    }
784
785    #[test]
786    fn test_merge_mount_specs() {
787        let env = EnvProperties {
788            sys_arch: "x86_64".to_string(),
789        };
790        let mut install: InstallConfiguration = toml::from_str(
791            r#"root-mount-spec = "UUID=old"
792boot-mount-spec = "UUID=oldboot"
793"#,
794        )
795        .unwrap();
796        let other = InstallConfiguration {
797            root_mount_spec: Some("LABEL=newroot".to_string()),
798            ..Default::default()
799        };
800        install.merge(other, &env);
801        // root_mount_spec should be overridden
802        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "LABEL=newroot");
803        // boot_mount_spec should remain unchanged
804        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "UUID=oldboot");
805    }
806
807    /// Empty mount specs are valid and signal to omit mount kargs entirely.
808    /// See https://github.com/bootc-dev/bootc/issues/1441
809    #[test]
810    fn test_parse_empty_mount_specs() {
811        let c: InstallConfigurationToplevel = toml::from_str(
812            r#"[install]
813root-mount-spec = ""
814boot-mount-spec = ""
815"#,
816        )
817        .unwrap();
818        let install = c.install.unwrap();
819        assert_eq!(install.root_mount_spec.as_deref().unwrap(), "");
820        assert_eq!(install.boot_mount_spec.as_deref().unwrap(), "");
821    }
822
823    #[test]
824    fn test_parse_bootupd_skip_boot_uuid() {
825        // Test parsing true
826        let c: InstallConfigurationToplevel = toml::from_str(
827            r#"[install.bootupd]
828skip-boot-uuid = true
829"#,
830        )
831        .unwrap();
832        assert_eq!(
833            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
834            true
835        );
836
837        // Test parsing false
838        let c: InstallConfigurationToplevel = toml::from_str(
839            r#"[install.bootupd]
840skip-boot-uuid = false
841"#,
842        )
843        .unwrap();
844        assert_eq!(
845            c.install.unwrap().bootupd.unwrap().skip_boot_uuid.unwrap(),
846            false
847        );
848
849        // Test default (not specified) is None
850        let c: InstallConfigurationToplevel = toml::from_str(
851            r#"[install]
852root-fs-type = "xfs"
853"#,
854        )
855        .unwrap();
856        assert!(c.install.unwrap().bootupd.is_none());
857    }
858
859    #[test]
860    fn test_merge_bootupd_skip_boot_uuid() {
861        let env = EnvProperties {
862            sys_arch: "x86_64".to_string(),
863        };
864        let mut install: InstallConfiguration = toml::from_str(
865            r#"[bootupd]
866skip-boot-uuid = false
867"#,
868        )
869        .unwrap();
870        let other = InstallConfiguration {
871            bootupd: Some(Bootupd {
872                skip_boot_uuid: Some(true),
873            }),
874            ..Default::default()
875        };
876        install.merge(other, &env);
877        // skip_boot_uuid should be overridden to true
878        assert_eq!(install.bootupd.unwrap().skip_boot_uuid.unwrap(), true);
879    }
880}
881
882#[test]
883fn test_parse_bootloader() {
884    let env = EnvProperties {
885        sys_arch: "x86_64".to_string(),
886    };
887
888    // 1. Test parsing "none"
889    let c: InstallConfigurationToplevel = toml::from_str(
890        r##"[install]
891bootloader = "none"
892"##,
893    )
894    .unwrap();
895    assert_eq!(c.install.unwrap().bootloader, Some(Bootloader::None));
896
897    // 2. Test parsing "grub"
898    let c: InstallConfigurationToplevel = toml::from_str(
899        r##"[install]
900bootloader = "grub"
901"##,
902    )
903    .unwrap();
904    assert_eq!(c.install.unwrap().bootloader, Some(Bootloader::Grub));
905
906    // 3. Test merging
907    // Initial config has "systemd"
908    let mut install: InstallConfiguration = toml::from_str(
909        r#"bootloader = "systemd"
910"#,
911    )
912    .unwrap();
913
914    // Incoming config has "none"
915    let other = InstallConfiguration {
916        bootloader: Some(Bootloader::None),
917        ..Default::default()
918    };
919
920    // Merge should overwrite systemd with none
921    install.merge(other, &env);
922    assert_eq!(install.bootloader, Some(Bootloader::None));
923}
924
925#[test]
926fn test_parse_discoverable_partitions() {
927    let c: InstallConfigurationToplevel = toml::from_str(
928        r##"[install]
929discoverable-partitions = true
930"##,
931    )
932    .unwrap();
933    assert_eq!(c.install.unwrap().discoverable_partitions, Some(true));
934
935    let c: InstallConfigurationToplevel = toml::from_str(
936        r##"[install]
937discoverable-partitions = false
938"##,
939    )
940    .unwrap();
941    assert_eq!(c.install.unwrap().discoverable_partitions, Some(false));
942
943    let c: InstallConfigurationToplevel = toml::from_str(
944        r##"[install]
945root-fs-type = "xfs"
946"##,
947    )
948    .unwrap();
949    assert_eq!(c.install.unwrap().discoverable_partitions, None);
950}
951
952#[test]
953fn test_parse_enforce_container_sigpolicy() {
954    let env = EnvProperties {
955        sys_arch: "x86_64".to_string(),
956    };
957
958    // Test parsing true and false
959    for (input, expected) in [("true", true), ("false", false)] {
960        let toml_str = format!(
961            r#"[install]
962enforce-container-sigpolicy = {input}
963"#
964        );
965        let c: InstallConfigurationToplevel = toml::from_str(&toml_str).unwrap();
966        assert_eq!(
967            c.install.unwrap().enforce_container_sigpolicy.unwrap(),
968            expected
969        );
970    }
971
972    // Default (not specified) is None
973    let c: InstallConfigurationToplevel = toml::from_str(
974        r#"[install]
975root-fs-type = "xfs"
976"#,
977    )
978    .unwrap();
979    assert!(c.install.unwrap().enforce_container_sigpolicy.is_none());
980
981    // Test merging: last write wins
982    let mut install: InstallConfiguration = toml::from_str(
983        r#"enforce-container-sigpolicy = false
984"#,
985    )
986    .unwrap();
987    let other = InstallConfiguration {
988        enforce_container_sigpolicy: Some(true),
989        ..Default::default()
990    };
991    install.merge(other, &env);
992    assert_eq!(install.enforce_container_sigpolicy.unwrap(), true);
993}