Skip to main content

bootc_initramfs_setup/
lib.rs

1//! Mount helpers for bootc-initramfs
2
3use std::{
4    ffi::OsString,
5    fmt::Debug,
6    io::ErrorKind,
7    os::fd::{AsFd, AsRawFd, OwnedFd},
8    path::{Path, PathBuf},
9};
10
11use anyhow::{Context, Result};
12use clap::Parser;
13use rustix::{
14    fs::{CWD, Mode, OFlags, major, minor, mkdirat, openat, stat, symlink},
15    io::Errno,
16    mount::{
17        FsMountFlags, MountAttrFlags, OpenTreeFlags, UnmountFlags, fsconfig_create,
18        fsconfig_set_string, fsmount, open_tree, unmount,
19    },
20    path,
21};
22
23use serde::Deserialize;
24
25use composefs::{
26    fsverity::{CompareVerityError, FsVerityHashValue, MeasureVerityError, Sha512HashValue},
27    mount::{FsHandle, MountOptions},
28    mountcompat::{overlayfs_set_fd, overlayfs_set_lower_and_data_fds, prepare_mount},
29    repository::{ImageNotFound, IntegrityPolicy, Repository, VerityState},
30};
31use composefs_boot::cmdline::{ComposefsCmdline, KARG_COMPOSEFS_DIGEST, KARG_V2};
32use composefs_ctl::composefs;
33use composefs_ctl::composefs_boot;
34
35use fn_error_context::context;
36
37use linux_kernel_cmdline::utf8::Cmdline;
38
39// mount_setattr syscall support
40const MOUNT_ATTR_RDONLY: u64 = 0x00000001;
41
42#[repr(C)]
43struct MountAttr {
44    attr_set: u64,
45    attr_clr: u64,
46    propagation: u64,
47    userns_fd: u64,
48}
49
50/// Set mount attributes using mount_setattr syscall
51#[context("Setting mount attributes")]
52#[allow(unsafe_code)]
53fn mount_setattr(fd: impl AsFd, flags: libc::c_int, attr: &MountAttr) -> Result<()> {
54    let ret = unsafe {
55        libc::syscall(
56            libc::SYS_mount_setattr,
57            fd.as_fd().as_raw_fd(),
58            c"".as_ptr(),
59            flags,
60            attr as *const MountAttr,
61            std::mem::size_of::<MountAttr>(),
62        )
63    };
64    if ret == -1 {
65        Err(std::io::Error::last_os_error())?;
66    }
67    Ok(())
68}
69
70/// Set a detached mount tree read-only without reopening its path.
71#[context("Setting mount readonly")]
72pub fn set_mount_readonly(fd: impl AsFd) -> Result<()> {
73    let attr = MountAttr {
74        attr_set: MOUNT_ATTR_RDONLY,
75        attr_clr: 0,
76        propagation: 0,
77        userns_fd: 0,
78    };
79    mount_setattr(fd, libc::AT_EMPTY_PATH, &attr)
80}
81
82/// Set a mount and every mount below it read-only.
83#[context("Setting mount tree readonly")]
84pub fn set_mount_tree_readonly(fd: impl AsFd) -> Result<()> {
85    let attr = MountAttr {
86        attr_set: MOUNT_ATTR_RDONLY,
87        attr_clr: 0,
88        propagation: 0,
89        userns_fd: 0,
90    };
91    mount_setattr(fd, libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, &attr)
92}
93
94/// Types of mounts supported by the configuration
95#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
96#[serde(rename_all = "lowercase")]
97pub enum MountType {
98    /// No mount; "root" is an alias meaning this dir is part of the root mount
99    #[serde(alias = "root")]
100    None,
101    /// Bind mount
102    Bind,
103    /// Overlay mount
104    Overlay,
105    /// Transient mount; "volatile" is an alias (Unix convention for tmpfs)
106    #[serde(alias = "volatile")]
107    Transient,
108}
109
110#[derive(Debug, Default, Deserialize, PartialEq)]
111struct RootConfig {
112    #[serde(default)]
113    transient: bool,
114}
115
116/// Configuration for mount operations
117#[derive(Debug, Default, Deserialize, PartialEq)]
118pub struct MountConfig {
119    /// The type of mount to use
120    pub mount: Option<MountType>,
121    #[serde(default)]
122    /// Whether this mount should be transient (temporary)
123    pub transient: bool,
124}
125
126/// The setup-root configuration, see `bootc-setup-root-conf(5)`.
127#[derive(Debug, Deserialize, Default, PartialEq)]
128pub struct Config {
129    /// How `/etc` is mounted
130    #[serde(default)]
131    pub etc: MountConfig,
132    /// How `/var` is mounted
133    #[serde(default)]
134    pub var: MountConfig,
135    #[serde(default)]
136    root: RootConfig,
137}
138
139/// Default path to the setup-root configuration file, relative to the booted root.
140pub const SETUP_ROOT_CONF_PATH: &str = "/usr/lib/composefs/setup-root-conf.toml";
141
142/// Returns `true` if the configuration at `path` requests a transient `/etc`
143/// overlay.  Used by the systemd generator to decide whether to emit the
144/// SELinux relabel unit *before* those mounts exist (the generator runs before
145/// `local-fs.target`).
146///
147/// Returns `false` if the file is absent or unreadable (safe default: no unit
148/// emitted for non-transient systems).
149pub fn config_has_transient_submounts(path: &std::path::Path) -> bool {
150    let text = match std::fs::read_to_string(path) {
151        Ok(t) => t,
152        Err(e) => {
153            tracing::debug!("Could not read {}: {e:#}", path.display());
154            return false;
155        }
156    };
157    let config: Config = match toml::from_str(&text) {
158        Ok(c) => c,
159        Err(e) => {
160            tracing::debug!("Could not parse {}: {e:#}", path.display());
161            return false;
162        }
163    };
164    // Only /etc overlay triggers the relabel unit.
165    let is_transient = |mc: &MountConfig| match mc.mount {
166        Some(mt) => mt == MountType::Transient,
167        None => mc.transient,
168    };
169    is_transient(&config.etc)
170}
171
172/// Command-line arguments
173#[derive(Parser, Debug)]
174pub struct Args {
175    #[arg(help = "Execute this command (for testing)")]
176    /// Execute this command (for testing)
177    pub cmd: Vec<OsString>,
178
179    #[arg(
180        long,
181        default_value = "/sysroot",
182        help = "sysroot directory in initramfs"
183    )]
184    /// sysroot directory in initramfs
185    pub sysroot: PathBuf,
186
187    #[arg(
188        long,
189        default_value = "/usr/lib/composefs/setup-root-conf.toml",
190        help = "Config path (for testing)"
191    )]
192    /// Config path (for testing)
193    pub config: PathBuf,
194
195    // we want to test in a userns, but can't mount erofs there
196    #[arg(long, help = "Bind mount root-fs from (for testing)")]
197    /// Bind mount root-fs from (for testing)
198    pub root_fs: Option<PathBuf>,
199
200    #[arg(long, help = "Kernel commandline args (for testing)")]
201    /// Kernel commandline args (for testing)
202    pub cmdline: Option<Cmdline<'static>>,
203
204    #[arg(long, help = "Mountpoint (don't replace sysroot, for testing)")]
205    /// Mountpoint (don't replace sysroot, for testing)
206    pub target: Option<PathBuf>,
207}
208
209/// Wrapper around [`composefs::mount::mount_at`]
210pub fn mount_at_wrapper(
211    fs_fd: impl AsFd,
212    dirfd: impl AsFd,
213    path: impl path::Arg + Debug + Clone,
214) -> Result<()> {
215    composefs::mount::mount_at(fs_fd, dirfd, path.clone())
216        .with_context(|| format!("Mounting at path {path:?}"))
217}
218
219/// Wrapper around [`rustix::fs::openat`]
220#[context("Opening dir {name:?}")]
221pub fn open_dir(dirfd: impl AsFd, name: impl AsRef<Path> + Debug) -> Result<OwnedFd> {
222    let res = openat(
223        dirfd,
224        name.as_ref(),
225        OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
226        Mode::empty(),
227    );
228
229    Ok(res?)
230}
231
232#[context("Ensure dir")]
233fn ensure_dir(dirfd: impl AsFd, name: &str, mode: Option<rustix::fs::Mode>) -> Result<OwnedFd> {
234    match mkdirat(dirfd.as_fd(), name, mode.unwrap_or(0o700.into())) {
235        Ok(()) | Err(Errno::EXIST) => {}
236        Err(err) => Err(err).with_context(|| format!("Creating dir {name}"))?,
237    }
238
239    open_dir(dirfd, name)
240}
241
242#[context("Bind mounting to path {path}")]
243fn bind_mount(fd: impl AsFd, path: &str) -> Result<OwnedFd> {
244    let res = open_tree(
245        fd.as_fd(),
246        path,
247        OpenTreeFlags::OPEN_TREE_CLONE
248            | OpenTreeFlags::OPEN_TREE_CLOEXEC
249            | OpenTreeFlags::AT_EMPTY_PATH,
250    );
251
252    Ok(res?)
253}
254
255/// Mount a tmpfs to use as the upper layer for an overlay.
256///
257/// TODO: sync these options with systemd's root mounting, there's some tweaks there for default tmpfs
258/// and we may want to make this configurable anyways i nthe future
259///
260/// See <https://github.com/containers/bootc/issues/1992>.
261#[context("Mounting tmpfs for overlay")]
262fn mount_tmpfs_for_overlay() -> Result<OwnedFd> {
263    let tmpfs = FsHandle::open("tmpfs")?;
264    fsconfig_create(tmpfs.as_fd())?;
265    Ok(fsmount(
266        tmpfs.as_fd(),
267        FsMountFlags::FSMOUNT_CLOEXEC,
268        MountAttrFlags::empty(),
269    )?)
270}
271
272/// Build an overlayfs fsmount fd from an existing state dir (upper+work).
273///
274/// upper is 0755: the merged view inherits permissions from upperdir, so 0700
275/// would make the mountpoint inaccessible to non-root processes.  work is
276/// kernel-internal and never visible; 0700 is fine.
277/// See: <https://github.com/composefs/composefs-rs/issues/287>
278fn build_overlay_fd(
279    base: impl AsFd,
280    state: impl AsFd,
281    source: &str,
282    mount_attr_flags: Option<MountAttrFlags>,
283) -> Result<OwnedFd> {
284    let upper = ensure_dir(state.as_fd(), "upper", Some(0o755.into()))?;
285    let work = ensure_dir(state.as_fd(), "work", Some(0o700.into()))?;
286
287    let overlayfs = FsHandle::open("overlay")?;
288    fsconfig_set_string(overlayfs.as_fd(), "source", source)?;
289    overlayfs_set_fd(overlayfs.as_fd(), "workdir", work.as_fd())?;
290    overlayfs_set_fd(overlayfs.as_fd(), "upperdir", upper.as_fd())?;
291    overlayfs_set_lower_and_data_fds(&overlayfs, base.as_fd(), &[])?;
292    fsconfig_create(overlayfs.as_fd())?;
293    Ok(fsmount(
294        overlayfs.as_fd(),
295        FsMountFlags::FSMOUNT_CLOEXEC,
296        mount_attr_flags.unwrap_or(MountAttrFlags::empty()),
297    )?)
298}
299
300/// Mount a persistent state directory as an overlay on top of `base`,
301/// attaching the result immediately at `.` relative to `base`.
302#[context("Mounting state as overlay")]
303fn overlay_state(
304    base: impl AsFd,
305    state: impl AsFd,
306    source: &str,
307    mount_attr_flags: Option<MountAttrFlags>,
308) -> Result<()> {
309    let fs = build_overlay_fd(&base, state, source, mount_attr_flags)?;
310    mount_at_wrapper(fs, base, ".").context("Moving mount")
311}
312
313/// Creates a transient overlayfs with the passed-in fd as the lowerdir.
314///
315/// Returns a detached (not yet attached) `OwnedFd` for the overlay mount.
316/// The caller is responsible for attaching it to the filesystem tree.
317///
318/// `source` is used verbatim as the overlay's `source` fsconfig option and
319/// appears in `/proc/self/mountinfo`.  For the composefs root, pass
320/// `"transient:composefs=<digest_hex>"` so that `composefs_booted()` can
321/// recover the verity digest from the mount source after switch-root.  For
322/// non-root transient mounts (e.g. `/usr`, `/var`) pass `"transient"`.
323///
324/// The SELinux label on `/` is fixed after boot by
325/// `bootc-early-overlay-relabel.service`; no initramfs-side xattr write is
326/// needed (kernel `fs_use_trans tmpfs` relabeling at policy-load time would
327/// overwrite anything written here).
328#[context("Creating transient overlayfs")]
329pub fn overlay_transient(
330    base: impl AsFd,
331    source: &str,
332    mount_attr_flags: Option<MountAttrFlags>,
333) -> Result<OwnedFd> {
334    let tmpfs = mount_tmpfs_for_overlay()?;
335    let state = prepare_mount(tmpfs)?;
336    build_overlay_fd(base, state, source, mount_attr_flags)
337}
338
339#[context("Opening rootfs")]
340fn open_root_fs(path: &Path) -> Result<OwnedFd> {
341    let rootfs = open_tree(
342        CWD,
343        path,
344        OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
345    )?;
346
347    set_mount_readonly(&rootfs)?;
348
349    Ok(rootfs)
350}
351
352/// Shown when a strict `composefs=` boot finds a root filesystem without
353/// fs-verity support, e.g. ext4 made without `-O verity` by another tool.
354const STRICT_BOOT_WITHOUT_FSVERITY_HELP: &str = "This boot entry requires fs-verity \
355(its composefs= argument has no '?'), but the root filesystem does not support it (for \
356example ext4 created without '-O verity'). To boot, select the previous deployment in \
357the boot menu, or add '?' right after 'composefs=' (or 'composefs.digest=') in this \
358entry's kernel command line, which boots without fs-verity enforcement. To enforce \
359fs-verity, reinstall onto a filesystem with fs-verity enabled";
360
361/// Whether `error` comes from a filesystem that cannot do fs-verity at all.
362fn is_fsverity_unsupported(error: &anyhow::Error) -> bool {
363    matches!(
364        error.downcast_ref::<CompareVerityError>(),
365        Some(CompareVerityError::Measure(
366            MeasureVerityError::FilesystemNotSupported
367        ))
368    )
369}
370
371/// Prepares a floating mount for composefs and returns the fd
372///
373/// # Arguments
374/// * sysroot                - fd for /sysroot
375/// * name                   - Expected fs-verity digest of the EROFS image
376/// * allow_missing_fsverity - Whether to allow mount without fsverity support
377#[context("Mounting composefs image")]
378pub fn mount_composefs_image(
379    sysroot: &OwnedFd,
380    name: &str,
381    allow_missing_fsverity: bool,
382) -> Result<OwnedFd> {
383    let digest = Sha512HashValue::from_hex(name).context("Parsing composefs digest")?;
384    // Use open_upgrade to handle upgrades from older composefs-rs versions
385    // that lack meta.json: it infers the algorithm and verity mode from
386    // existing objects, writes meta.json, and opens normally.
387    let (mut repo, _upgraded) = Repository::<Sha512HashValue>::open_upgrade(sysroot, "composefs")?;
388    // The cmdline decides the mode, not the repository's mutable metadata:
389    // meta.json lives on the same unverified filesystem as the images, and
390    // older installs can legitimately have written it without fs-verity.
391    // The explicit policy is final for this handle.
392    let verity = if allow_missing_fsverity {
393        // Missing fs-verity is tolerated, but an image whose digest can be
394        // measured still has to match.
395        VerityState::Disabled
396    } else {
397        // fs-verity is required on the image and on every object it serves.
398        VerityState::Enabled
399    };
400    repo.set_integrity_policy(IntegrityPolicy::Explicit(verity))?;
401    // mount_digest only ever looks at images/<digest>, never at named refs.
402    let rootfs = repo
403        .mount_digest(&digest, &MountOptions::default())
404        .map_err(|error| {
405            if !allow_missing_fsverity && is_fsverity_unsupported(&error) {
406                error.context(STRICT_BOOT_WITHOUT_FSVERITY_HELP)
407            } else {
408                error
409            }
410        })?;
411
412    set_mount_readonly(&rootfs)?;
413
414    Ok(rootfs)
415}
416
417fn parse_composefs_candidates(cmdline: &str) -> Result<Vec<ComposefsCmdline<Sha512HashValue>>> {
418    let mut candidates = Vec::new();
419    let mut seen_legacy = false;
420
421    // Parse the cmdline with the kernel parser so quoted parameters remain a
422    // single parameter. setup_root checks that all candidates agree on policy.
423    for parameter in Cmdline::from(cmdline).iter() {
424        let key = parameter.key();
425        if &*key != KARG_COMPOSEFS_DIGEST && &*key != KARG_V2 {
426            continue;
427        }
428
429        let value = parameter
430            .value()
431            .ok_or_else(|| anyhow::anyhow!("{key}= composefs kernel argument has no value"))?;
432        if &*key == KARG_V2 {
433            anyhow::ensure!(
434                !seen_legacy,
435                "duplicate {KARG_V2}= composefs kernel argument"
436            );
437            seen_legacy = true;
438        }
439
440        let parameter = format!("{key}={value}");
441        if let Some(candidate) = ComposefsCmdline::<Sha512HashValue>::from_cmdline(&parameter)? {
442            candidates.push(candidate);
443        }
444    }
445    Ok(candidates)
446}
447
448fn select_composefs_candidate<T>(
449    candidates: &[ComposefsCmdline<Sha512HashValue>],
450    mut mount: impl FnMut(&ComposefsCmdline<Sha512HashValue>) -> Result<Option<T>>,
451) -> Result<(T, ComposefsCmdline<Sha512HashValue>)> {
452    for candidate in candidates {
453        if let Some(mounted) = mount(candidate)? {
454            return Ok((mounted, candidate.clone()));
455        }
456    }
457    let tried = candidates
458        .iter()
459        .map(|candidate| candidate.digest().to_hex())
460        .collect::<Vec<_>>()
461        .join(", ");
462    anyhow::bail!("no composefs image found (tried: {tried})")
463}
464
465fn mount_composefs_candidate(
466    sysroot: &OwnedFd,
467    candidate: &ComposefsCmdline<Sha512HashValue>,
468    allow_missing_fsverity: bool,
469) -> Result<Option<OwnedFd>> {
470    // A candidate must not relax the fs-verity policy selected for this boot.
471    if candidate.is_insecure() && !allow_missing_fsverity {
472        anyhow::bail!(
473            "composefs candidate requests insecure fs-verity, but the boot policy is strict"
474        );
475    }
476
477    match mount_composefs_image(
478        sysroot,
479        &candidate.digest().to_hex(),
480        allow_missing_fsverity,
481    ) {
482        Ok(rootfs) => Ok(Some(rootfs)),
483        Err(error) if error.downcast_ref::<ImageNotFound>().is_some() => Ok(None),
484        Err(error) => Err(error),
485    }
486}
487
488/// Mounts a subdirectory with the specified configuration
489#[context("Mounting subdirectory")]
490pub fn mount_subdir(
491    new_root: impl AsFd,
492    state: impl AsFd,
493    subdir: &str,
494    config: MountConfig,
495    default: MountType,
496) -> Result<()> {
497    let mount_type = match config.mount {
498        Some(mt) => mt,
499        None => match config.transient {
500            true => MountType::Transient,
501            false => default,
502        },
503    };
504
505    match mount_type {
506        MountType::None => Ok(()),
507        MountType::Bind => Ok(mount_at_wrapper(
508            bind_mount(&state, subdir)?,
509            &new_root,
510            subdir,
511        )?),
512        MountType::Overlay => overlay_state(
513            open_dir(&new_root, subdir)?,
514            open_dir(&state, subdir)?,
515            "overlay",
516            None,
517        ),
518        MountType::Transient => {
519            // For subdirectory transient mounts, create the overlay and immediately
520            // attach it at the subdirectory path in new_root.
521            let subdir_fd = open_dir(&new_root, subdir)?;
522            let overlay_fd = overlay_transient(subdir_fd.as_fd(), "transient", None)?;
523            mount_at_wrapper(overlay_fd, &new_root, subdir)
524        }
525    }
526}
527
528#[context("GPT workaround")]
529/// Workaround for /dev/gpt-auto-root
530pub fn gpt_workaround() -> Result<()> {
531    // https://github.com/systemd/systemd/issues/35017
532    let rootdev = stat("/dev/gpt-auto-root");
533
534    let rootdev = match rootdev {
535        Ok(r) => r,
536        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(()),
537        Err(e) => Err(e)?,
538    };
539
540    let target = format!(
541        "/dev/block/{}:{}",
542        major(rootdev.st_rdev),
543        minor(rootdev.st_rdev)
544    );
545    // Handle the case where the symlink was already created before bootc
546    // runs, e.g. by ostree-prepare-root
547    // (https://github.com/ostreedev/ostree/pull/3608).
548    match symlink(target, "/run/systemd/volatile-root") {
549        Ok(()) | Err(Errno::EXIST) => Ok(()),
550        Err(err) => Err(err).context("Creating /run/systemd/volatile-root"),
551    }
552}
553
554/// Sets up /sysroot for switch-root
555#[context("Setting up /sysroot")]
556pub fn setup_root(args: Args) -> Result<()> {
557    let config = match std::fs::read_to_string(args.config) {
558        Ok(text) => toml::from_str(&text)?,
559        Err(err) if err.kind() == ErrorKind::NotFound => Config::default(),
560        Err(err) => Err(err)?,
561    };
562
563    let sysroot = open_dir(CWD, &args.sysroot)
564        .with_context(|| format!("Failed to open sysroot {:?}", args.sysroot))?;
565
566    let cmdline = args
567        .cmdline
568        .unwrap_or(Cmdline::from_proc().context("Failed to read cmdline")?);
569
570    // Auto-detect systemd.volatile=state: if the kernel cmdline requests a
571    // volatile /var via the systemd fstab-generator, skip our initramfs
572    // bind-mount of /var from the deployment state directory.  This leaves
573    // /var as an empty directory from the composefs image so that
574    // systemd-fstab-generator can mount a fresh tmpfs there at local-fs.target.
575    // An explicit `[var] mount = "none"` in setup-root-conf.toml has the same
576    // effect; the cmdline check is a convenience so users only need the kargs.d
577    // entry without also editing setup-root-conf.toml.
578    let config = {
579        let mut config = config;
580        // value_of returns None for a missing key, Some("") for a bare flag,
581        // or Some("state") / Some("overlay") / Some("yes") for key=value form.
582        let volatile_val = cmdline.value_of("systemd.volatile");
583        let var_volatile = matches!(volatile_val, Some("state") | Some("overlay"));
584        if var_volatile && config.var.mount.is_none() && !config.var.transient {
585            tracing::debug!(
586                "systemd.volatile={} detected; skipping /var state bind-mount",
587                volatile_val.unwrap_or("")
588            );
589            config.var.mount = Some(MountType::None);
590        }
591        config
592    };
593
594    let candidates =
595        parse_composefs_candidates(&cmdline).context("Failed to parse composefs cmdline")?;
596    if candidates.is_empty() {
597        anyhow::bail!("No composefs image in cmdline");
598    }
599    let allow_missing_fsverity = candidates[0].is_insecure();
600    if candidates
601        .iter()
602        .any(|candidate| candidate.is_insecure() != allow_missing_fsverity)
603    {
604        anyhow::bail!("composefs candidates have mixed fs-verity policy");
605    }
606
607    let (new_root, composefs_info) = match &args.root_fs {
608        Some(path) => (
609            open_root_fs(path).context("Failed to clone specified root fs")?,
610            candidates[0].clone(),
611        ),
612        None => select_composefs_candidate(&candidates, |candidate| {
613            mount_composefs_candidate(&sysroot, candidate, allow_missing_fsverity)
614        })?,
615    };
616
617    // we need to clone this before the next step to make sure we get the old one
618    let sysroot_clone = bind_mount(&sysroot, "")?;
619
620    set_mount_readonly(&sysroot_clone)?;
621
622    let mount_target = args.target.unwrap_or(args.sysroot.clone());
623
624    // Ideally we build the new root filesystem together before we mount it, but that only works on
625    // 6.15 and later.  Before 6.15 we can't mount into a floating tree, so mount it first.  This
626    // will leave an abandoned clone of the sysroot mounted under it, but that's OK for now.
627    if cfg!(feature = "pre-6.15") {
628        mount_at_wrapper(&new_root, CWD, &mount_target)?;
629    }
630
631    // When transient root is enabled, place an overlay on top of the composefs.
632    // On pre-6.15, since the composefs is already attached at `mount_target`,
633    // the overlay is also immediately attached there.  We then open the overlay
634    // via its path so that subsequent mounts target the visible merged tree.
635    //
636    // On 6.15+, the whole tree is assembled in floating mode; `overlay_transient`
637    // returns a detached overlay fd that we can directly mount into.
638    //
639    // `new_root` always refers to the composefs fd; mounting via it after the
640    // overlay is in place would land in the hidden lower layer.
641    let transient_overlay_fd: Option<OwnedFd> = if config.root.transient {
642        let overlay_fd = overlay_transient(
643            &new_root,
644            &format!("transient:composefs={}", composefs_info.digest().to_hex()),
645            None,
646        )?;
647
648        if cfg!(feature = "pre-6.15") {
649            // In pre-6.15, the composefs is already attached at `mount_target`.
650            // Attach the overlay on top of it, then reopen the path to get a
651            // dirfd that resolves through the overlay (not the hidden composefs).
652            mount_at_wrapper(&overlay_fd, CWD, &mount_target)
653                .context("Moving transient overlay onto sysroot")?;
654            Some(open_dir(CWD, &mount_target).context("Opening attached overlay root")?)
655        } else {
656            // On 6.15+ we assemble in floating mode; use the detached overlay fd
657            // directly for subsequent mounts into the tree.
658            Some(overlay_fd)
659        }
660    } else {
661        None
662    };
663
664    // When transient root is active the overlay sits on top of the composefs.
665    // Mounts placed via `new_root` would land in the composefs lower layer and
666    // be invisible from the running system.  Use the overlay fd for all
667    // post-overlay mounts (sysroot, etc, var) so they appear in the merged view.
668    let visible_root: &dyn AsFd = transient_overlay_fd
669        .as_ref()
670        .map_or(&new_root as &dyn AsFd, |fd| fd as &dyn AsFd);
671
672    // Mount the physical sysroot (with the composefs repo) into the new root
673    // so that `bootc status` and other tools can find it after switch-root.
674    match composefs::mount::mount_at(&sysroot_clone, visible_root, "sysroot") {
675        Ok(()) | Err(Errno::NOENT) => {}
676        Err(err) => Err(err)?,
677    }
678
679    // etc + var
680    let state = open_dir(
681        open_dir(&sysroot, "state/deploy")?,
682        composefs_info.digest().to_hex(),
683    )?;
684    mount_subdir(visible_root, &state, "etc", config.etc, MountType::Bind)?;
685    // /var is bind-mounted from the deployment state directory by default.
686    // The systemd.volatile=state cmdline detection above (or an explicit
687    // [var] mount = "none" in setup-root-conf.toml) can change this to
688    // MountType::None, which skips the bind-mount entirely and leaves /var
689    // as an empty directory from the composefs image for systemd to fill.
690    mount_subdir(visible_root, &state, "var", config.var, MountType::Bind)?;
691
692    if cfg!(not(feature = "pre-6.15")) {
693        // Replace the /sysroot with the new composed root filesystem.
694        // When a transient overlay is active, mount it rather than the bare
695        // composefs so the running system sees the writable merged view.
696        unmount(&args.sysroot, UnmountFlags::DETACH)?;
697        mount_at_wrapper(visible_root, CWD, &mount_target)?;
698    }
699
700    Ok(())
701}
702
703#[cfg(test)]
704mod tests {
705    use std::{fs, fs::File};
706
707    use composefs::{
708        fsverity::{Algorithm, CompareVerityError, MeasureVerityError, compute_verity},
709        repository::RepositoryConfig,
710    };
711
712    use super::*;
713
714    #[test]
715    fn test_strict_mount_rejects_unverified_images() -> Result<()> {
716        // A strict cmdline must not be overridden by the repository's insecure
717        // mode, and an image under images/refs/ must not stand in for the
718        // digest whatever the cmdline says. All of these are rejected before
719        // anything is mounted, so this runs unprivileged and on tmpfs.
720        let tmp = tempfile::tempdir()?;
721        let sysroot: OwnedFd = File::open(tmp.path())?.into();
722        let (repo, _) = Repository::<Sha512HashValue>::init_path(
723            &sysroot,
724            "composefs",
725            RepositoryConfig::new(Algorithm::SHA512).set_insecure(),
726        )?;
727        drop(repo);
728        let images = tmp.path().join("composefs/images");
729        fs::create_dir_all(images.join("refs"))?;
730        let data = b"unverified image";
731        let digest = compute_verity::<Sha512HashValue>(data).to_hex();
732
733        fs::write(images.join("refs").join(&digest), data)?;
734        for allow_missing_fsverity in [false, true] {
735            let error =
736                mount_composefs_image(&sysroot, &digest, allow_missing_fsverity).unwrap_err();
737            assert!(error.downcast_ref::<ImageNotFound>().is_some(), "{error:#}");
738        }
739
740        fs::write(images.join(&digest), data)?;
741        let error = mount_composefs_image(&sysroot, &digest, false).unwrap_err();
742        assert!(
743            matches!(
744                error.downcast_ref::<CompareVerityError>(),
745                Some(CompareVerityError::Measure(
746                    MeasureVerityError::VerityMissing | MeasureVerityError::FilesystemNotSupported
747                ))
748            ),
749            "{error:#}"
750        );
751        // Only a filesystem without fs-verity support gets the recovery hint.
752        assert_eq!(
753            format!("{error:#}").contains(STRICT_BOOT_WITHOUT_FSVERITY_HELP),
754            is_fsverity_unsupported(&error),
755            "{error:#}"
756        );
757
758        let missing = Sha512HashValue::EMPTY.to_hex();
759        let error = mount_composefs_image(&sysroot, &missing, false).unwrap_err();
760        assert!(error.downcast_ref::<ImageNotFound>().is_some(), "{error:#}");
761        Ok(())
762    }
763
764    fn parse(toml: &str) -> Config {
765        toml::from_str(toml).expect("TOML parse failed")
766    }
767
768    #[test]
769    fn test_config_defaults() {
770        let config = parse("");
771        assert_eq!(
772            config,
773            Config {
774                etc: MountConfig {
775                    mount: None,
776                    transient: false
777                },
778                var: MountConfig {
779                    mount: None,
780                    transient: false
781                },
782                root: RootConfig { transient: false },
783            }
784        );
785    }
786
787    #[test]
788    fn test_mounttype_none() {
789        let config = parse("[etc]\nmount = \"none\"");
790        assert_eq!(config.etc.mount, Some(MountType::None));
791    }
792
793    #[test]
794    fn test_mounttype_root_alias() {
795        let config = parse("[etc]\nmount = \"root\"");
796        assert_eq!(config.etc.mount, Some(MountType::None));
797    }
798
799    #[test]
800    fn test_etc_transient_flag() {
801        let config = parse("[etc]\ntransient = true");
802        assert_eq!(config.etc.transient, true);
803        assert_eq!(config.etc.mount, None);
804    }
805
806    #[test]
807    fn test_var_none() {
808        // mount = "none" skips the state bind-mount; combine with
809        // systemd.volatile=state karg to get a fresh tmpfs on every boot.
810        let config = parse("[var]\nmount = \"none\"");
811        assert_eq!(config.var.mount, Some(MountType::None));
812    }
813
814    #[test]
815    fn test_root_transient() {
816        let config = parse("[root]\ntransient = true");
817        assert_eq!(config.root.transient, true);
818    }
819
820    #[test]
821    fn test_combined_config() {
822        let config = parse("[root]\ntransient = true\n[etc]\nmount = \"root\"");
823        assert_eq!(config.root.transient, true);
824        assert_eq!(config.etc.mount, Some(MountType::None));
825    }
826
827    fn v1(digest: Sha512HashValue, insecure: bool) -> String {
828        ComposefsCmdline::new_v1(digest, insecure).to_cmdline_arg()
829    }
830
831    fn v2(digest: Sha512HashValue, insecure: bool) -> String {
832        ComposefsCmdline::new_v2(digest, insecure).to_cmdline_arg()
833    }
834
835    #[test]
836    fn test_composefs_candidate_priority_and_selected_state() {
837        let v1_digest = Sha512HashValue::EMPTY;
838        let v2_digest = Sha512HashValue::from_hex("aa".repeat(64)).unwrap();
839        let cmdline = format!(
840            "quiet {} {}",
841            v1(v1_digest.clone(), false),
842            v2(v2_digest.clone(), false)
843        );
844        let candidates = parse_composefs_candidates(&cmdline).unwrap();
845        let (selected, selected_cmdline) = select_composefs_candidate(&candidates, |candidate| {
846            Ok((candidate.digest() == &v2_digest).then_some(candidate.digest().to_hex()))
847        })
848        .unwrap();
849        assert_eq!(selected, v2_digest.to_hex());
850        assert_eq!(selected_cmdline.digest(), &v2_digest);
851
852        let (selected, _) = select_composefs_candidate(&candidates, |candidate| {
853            Ok(Some(candidate.digest().to_hex()))
854        })
855        .unwrap();
856        assert_eq!(selected, v1_digest.to_hex());
857    }
858
859    #[test]
860    fn test_composefs_candidate_parse_and_policy_errors() {
861        let malformed = ["composefs=not-a-digest", "composefs.digest=v9-sha512-12:aa"];
862        for cmdline in malformed {
863            assert!(
864                parse_composefs_candidates(cmdline).is_err(),
865                "expected parse error for {cmdline:?}"
866            );
867        }
868
869        let strict = parse_composefs_candidates(&v1(Sha512HashValue::EMPTY, false)).unwrap();
870        let insecure = parse_composefs_candidates(&v2(Sha512HashValue::EMPTY, true)).unwrap();
871        assert_ne!(strict[0].is_insecure(), insecure[0].is_insecure());
872    }
873
874    #[test]
875    fn test_composefs_candidate_parser_cases() {
876        let first = Sha512HashValue::EMPTY;
877        let second = Sha512HashValue::from_hex("aa".repeat(64)).unwrap();
878        let cases = [
879            ("single v1", v1(first.clone(), false), vec![first.clone()]),
880            ("single v2", v2(first.clone(), false), vec![first.clone()]),
881            (
882                "dual preserves cmdline order",
883                format!("{} {}", v2(first.clone(), false), v1(second.clone(), false)),
884                vec![first.clone(), second.clone()],
885            ),
886            (
887                "quoted parameter",
888                format!("quiet \"{}\" rw", v1(first.clone(), false)),
889                vec![first.clone()],
890            ),
891            (
892                "skips sha256 descriptor",
893                format!(
894                    "composefs.digest=v1-sha256-12:{} {} {}",
895                    "aa".repeat(32),
896                    v1(first.clone(), false),
897                    v2(second.clone(), false)
898                ),
899                vec![first.clone(), second.clone()],
900            ),
901        ];
902
903        for (name, cmdline, expected_digests) in cases {
904            let candidates = parse_composefs_candidates(&cmdline)
905                .unwrap_or_else(|error| panic!("{name} unexpectedly failed: {error:#}"));
906            assert_eq!(
907                candidates
908                    .iter()
909                    .map(|candidate| candidate.digest().clone())
910                    .collect::<Vec<_>>(),
911                expected_digests,
912                "case {name}"
913            );
914        }
915
916        let duplicate_cases = [
917            format!("{} {}", v2(first.clone(), false), v2(second.clone(), false)),
918            format!("{} {}", v2(first.clone(), false), v2(first.clone(), false)),
919        ];
920        for cmdline in duplicate_cases {
921            assert!(
922                parse_composefs_candidates(&cmdline).is_err(),
923                "duplicate recognized key accepted: {cmdline}"
924            );
925        }
926
927        let irrelevant = [
928            "quiet splash rw",
929            "root=UUID=abc composefs.digest=v1-sha256-12:aa",
930        ];
931        for cmdline in irrelevant {
932            assert!(
933                parse_composefs_candidates(cmdline).unwrap().is_empty(),
934                "irrelevant argument was treated as a candidate: {cmdline}"
935            );
936        }
937    }
938}