Skip to main content

bootc_lib/store/
mod.rs

1//! The [`Storage`] type holds references to three different types of
2//! storage that together implement the unified storage model.
3//!
4//! # Planned three-store architecture
5//!
6//! The planned architecture for unified storage involves three content stores that
7//! share physical disk blocks on a reflink-capable filesystem (XFS, btrfs):
8//!
9//! 1. **bootc-owned containers-storage** at `/sysroot/ostree/bootc/storage`
10//!    (overlay driver) — the image is accessible to podman and shares layers
11//!    with Logically Bound Images.
12//! 2. **composefs object store** at `/sysroot/composefs/objects/`
13//!    (SHA-512 content-addressed) — used by composefs-boot to mount the
14//!    rootfs as EROFS.  Populated from containers-storage via `FICLONE`
15//!    (`composefs_oci::pull` with `ZeroCopy`).
16//! 3. **ostree bare repo** at `/sysroot/ostree/repo/objects/`
17//!    (SHA-256 content-addressed) — provides deployment, rollback, fsck, and
18//!    delta updates.  Populated from the composefs object store via `FICLONE`
19//!    (`import_from_composefs_repo`).
20//!
21//! Each `FICLONE` ioctl lets the kernel mark source and destination extents as
22//! copy-on-write siblings with no userspace data movement. On ext4 (no
23//! reflinks), each step falls back to a byte copy.
24//!
25//! ## Implementation Plan
26//!
27//! The containers-storage → composefs step (arrow 1) is already implemented
28//! for the composefs boot backend in `crates/lib/src/bootc_composefs/repo.rs`
29//! via `pull_composefs_unified`.
30//!
31//! Wiring all three steps together for the ostree backend is the major planned work.
32//! The composefs → ostree step (arrow 2) was proven by the `composefs-to-ostree`
33//! spike branch. The planned implementation for the ostree backend will:
34//!
35//! 1. Perform a lazy cached probe (`reflinks_supported`) at install time.
36//! 2. Pull into containers-storage first (Stage 1).
37//! 3. Use `composefs_oci::pull` with `LocalFetchOpt::ZeroCopy` to populate composefs (Stage 2).
38//! 4. Finally, synthesize the ostree commit by walking the composefs tree,
39//!    reading metadata, computing SELinux labels, computing the ostree checksum,
40//!    and `FICLONE`ing into the ostree bare repo (Stage 3).
41//!
42//! ## Long-term: Global composefs store
43//!
44//! The ultimate planned state (the "composefs-as-storage" plan) is to have podman's
45//! composefs backend natively write objects to `/sysroot/composefs` directly, bypassing
46//! even `containers-storage`. This would mean flatpak, podman, and bootc all share exactly
47//! one global pool of content-addressed, deduplicated files.
48//!
49//! ## Why composefs in the middle
50//!
51//! The old unified storage path (containers-storage → skopeo tar → ostree)
52//! serialized layers twice. composefs-ctl's `ZeroCopy` pull mode instead walks
53//! the overlay `diff/` directories and FICLONEs each file into the composefs
54//! object store keyed by SHA-512 fsverity digest — no tar involved.
55//! See [container-libs#144](https://github.com/containers/container-libs/issues/144).
56//!
57//! ## Why reflink and not hardlink between composefs and ostree
58//!
59//! composefs is content-addressed by SHA-512 of raw bytes: two paths with
60//! identical content share one composefs inode. ostree bare mode stores
61//! uid/gid/mode/xattrs including `security.selinux` on each inode. Two files
62//! with the same bytes but different SELinux labels produce different ostree
63//! checksums but share one composefs object. One inode can hold only one
64//! `security.selinux` value, so hardlinking would silently corrupt labels.
65//! Reflink gives each ostree object its own inode while sharing disk extents.
66//!
67//! ## Reflink probe
68//!
69//! The reflink probe is performed lazily and cached. It creates
70//! two anonymous temporary files (via `O_TMPFILE`, no
71//! cleanup needed), writes one byte to the source, and attempts
72//! `ioctl(FICLONE)`. Returns `true` on success, `false` on `EOPNOTSUPP` or
73//! `EXDEV`. The probe directory is `composefs/objects` if it already exists,
74//! otherwise the physical root itself.
75//!
76//! # OSTree
77//!
78//! The default backend for the bootable container store; this
79//! lives in `/ostree` in the physical root.
80//!
81//! # containers-storage:
82//!
83//! Later, bootc gained support for Logically Bound Images.
84//! On ostree systems this is a `containers-storage:` instance that
85//! lives in `/ostree/bootc/storage`.  On composefs systems the
86//! physical location is `/composefs/bootc/storage` with a compat
87//! symlink at `ostree/bootc -> ../composefs/bootc`.
88//!
89//! # composefs
90//!
91//! This lives in `/composefs` in the physical root.
92
93use std::cell::OnceCell;
94use std::ops::Deref;
95use std::sync::Arc;
96
97use anyhow::{Context, Result};
98use bootc_mount::tempmount::TempMount;
99use bootc_mount::{Filesystem, run_findmnt};
100use camino::{Utf8Path, Utf8PathBuf};
101use cap_std_ext::cap_std;
102use cap_std_ext::cap_std::fs::{
103    Dir, DirBuilder, DirBuilderExt as _, Permissions, PermissionsExt as _,
104};
105use cap_std_ext::dirext::CapStdExtDirExt;
106use fn_error_context::context;
107
108use ocidir::cap_std::ambient_authority;
109use ostree_ext::container_utils::ostree_booted;
110use ostree_ext::prelude::FileExt;
111use ostree_ext::sysroot::SysrootLock;
112use ostree_ext::{gio, ostree};
113use rustix::{fd::AsFd, fs::Mode, fs::StatVfsMountFlags};
114
115use composefs::fsverity::Sha512HashValue;
116use composefs::repository::{RepositoryConfig, RepositoryOpenError};
117use composefs_ctl::composefs;
118
119use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix;
120use crate::bootc_composefs::boot::{
121    BootType, EFI_LINUX, mount_esp, mount_esp_readonly, mount_esp_writable,
122};
123use crate::bootc_composefs::boot_partition::BootPartition;
124use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader};
125use crate::bootc_composefs::{aboot, state::read_boot_type};
126use crate::composefs_consts::{BLS_ENTRY_FILE_PREFIX, TYPE1_ENT_PATH};
127use crate::install::BOOT;
128use crate::lsm;
129use crate::podstorage::CStorage;
130use crate::spec::{BootloaderKind, ImageStatus};
131use crate::utils::{EfiError, deployment_fd, open_dir_remount_rw, read_uefi_var};
132
133/// See <https://github.com/containers/composefs-rs/issues/159>
134pub type ComposefsRepository = composefs::repository::Repository<Sha512HashValue>;
135
136/// Configure new repositories to retain boot images for both supported formats.
137pub(crate) fn set_dual_erofs_formats(config: &mut RepositoryConfig) {
138    config.erofs_formats = composefs::erofs::format::FormatConfig {
139        default: composefs::erofs::format::FormatVersion::V1,
140        extra: [composefs::erofs::format::FormatVersion::V2].into(),
141    };
142}
143
144/// Path to the physical root
145pub const SYSROOT: &str = "sysroot";
146
147/// The toplevel composefs directory path
148pub const COMPOSEFS: &str = "composefs";
149
150/// The mode for the composefs directory; this is intentionally restrictive
151/// to avoid leaking information.
152pub(crate) const COMPOSEFS_MODE: Mode = Mode::from_raw_mode(0o700);
153
154/// Ensure the composefs directory exists in the given physical root
155/// with the correct permissions (mode 0700).
156pub(crate) fn ensure_composefs_dir(physical_root: &Dir) -> Result<()> {
157    let mut db = DirBuilder::new();
158    db.mode(COMPOSEFS_MODE.as_raw_mode());
159    physical_root
160        .ensure_dir_with(COMPOSEFS, &db)
161        .context("Creating composefs directory")?;
162    // Always update permissions, in case the directory pre-existed
163    // with incorrect mode (e.g. from an older version of bootc).
164    physical_root
165        .set_permissions(
166            COMPOSEFS,
167            Permissions::from_mode(COMPOSEFS_MODE.as_raw_mode()),
168        )
169        .context("Setting composefs directory permissions")?;
170    Ok(())
171}
172
173/// The path to the bootc root directory, relative to the physical
174/// system root.  On ostree systems this is a real directory; on composefs
175/// systems it is a symlink to `../composefs/bootc` (see
176/// [`ensure_composefs_bootc_link`]).
177pub(crate) const BOOTC_ROOT: &str = "ostree/bootc";
178
179/// The "real" bootc root for composefs-native systems, relative to the
180/// physical system root.
181pub(crate) const COMPOSEFS_BOOTC_ROOT: &str = "composefs/bootc";
182
183/// On a composefs install the containers-storage lives under
184/// `composefs/bootc/storage`.  To keep the rest of the code (and the
185/// `/usr/lib/bootc/storage` symlink which points through `ostree/bootc`)
186/// working, we create:
187///
188///   `ostree/bootc -> ../composefs/bootc`
189///
190/// This function is idempotent.
191pub(crate) fn ensure_composefs_bootc_link(physical_root: &Dir) -> Result<()> {
192    // Ensure the real directory exists
193    physical_root
194        .create_dir_all(COMPOSEFS_BOOTC_ROOT)
195        .with_context(|| format!("Creating {COMPOSEFS_BOOTC_ROOT}"))?;
196
197    // Create the `ostree/` parent if needed (it won't exist on a pure
198    // composefs install that never touched ostree).
199    physical_root
200        .create_dir_all("ostree")
201        .context("Creating ostree directory")?;
202
203    // If ostree/bootc already exists as a real directory (e.g. from an
204    // older install or from the ostree path), leave it alone — this
205    // function is only for fresh composefs installs.
206    match physical_root.symlink_metadata(BOOTC_ROOT) {
207        Ok(meta) if meta.is_symlink() => {
208            // Already a symlink — nothing to do
209            return Ok(());
210        }
211        Ok(_meta) => {
212            // It's a real directory.  This shouldn't happen during a fresh
213            // composefs install, but if it does just leave it.
214            tracing::warn!(
215                "{BOOTC_ROOT} already exists as a directory, not replacing with symlink"
216            );
217            return Ok(());
218        }
219        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
220            // Good — doesn't exist yet, we'll create the symlink
221        }
222        Err(e) => return Err(e).context(format!("Querying {BOOTC_ROOT}")),
223    }
224
225    physical_root
226        .symlink_contents(format!("../{COMPOSEFS_BOOTC_ROOT}"), BOOTC_ROOT)
227        .with_context(|| format!("Creating {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT} symlink"))?;
228
229    tracing::info!("Created {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT}");
230    Ok(())
231}
232
233/// Storage accessor for a booted system.
234///
235/// This wraps [`Storage`] and can determine whether the system is booted
236/// via ostree or composefs, providing a unified interface for both.
237pub(crate) struct BootedStorage {
238    pub(crate) storage: Storage,
239}
240
241impl Deref for BootedStorage {
242    type Target = Storage;
243
244    fn deref(&self) -> &Self::Target {
245        &self.storage
246    }
247}
248
249/// Represents an ostree-based boot environment
250pub struct BootedOstree<'a> {
251    pub(crate) sysroot: &'a SysrootLock,
252    pub(crate) deployment: ostree::Deployment,
253}
254
255impl<'a> BootedOstree<'a> {
256    /// Get the ostree repository
257    pub(crate) fn repo(&self) -> ostree::Repo {
258        self.sysroot.repo()
259    }
260
261    /// Get the stateroot name
262    pub(crate) fn stateroot(&self) -> ostree::glib::GString {
263        self.deployment.osname()
264    }
265}
266
267/// Represents a composefs-based boot environment
268pub struct BootedComposefs {
269    pub repo: Arc<ComposefsRepository>,
270    pub cmdline: &'static ComposefsCmdline,
271}
272
273/// Discriminated union representing the boot storage backend.
274///
275/// The runtime environment in which bootc is executing.
276pub(crate) enum Environment {
277    /// System booted via ostree
278    OstreeBooted,
279    /// System booted via composefs
280    ComposefsBooted(ComposefsCmdline),
281    /// Running in a container
282    Container,
283    /// Other (not booted via bootc)
284    Other,
285}
286
287/// Whether a `BootedStorage` caller intends to write to the ESP.
288///
289/// Only meaningful for `Environment::ComposefsBooted`; ignored elsewhere.
290/// Read-only callers (e.g. `bootc status`) pass `ReadOnly` so a pre-mounted
291/// ro ESP can be cloned as-is; write callers (e.g. `bootc upgrade`) pass
292/// `ReadWrite` so a pre-mounted ro ESP is remounted rw in the private
293/// mount namespace.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub(crate) enum EspAccess {
296    ReadOnly,
297    ReadWrite,
298}
299
300impl Environment {
301    /// Detect the current runtime environment.
302    pub(crate) fn detect() -> Result<Self> {
303        if ostree_ext::container_utils::running_in_container() {
304            return Ok(Self::Container);
305        }
306
307        if let Some(cmdline) = composefs_booted()? {
308            return Ok(Self::ComposefsBooted(cmdline.clone()));
309        }
310
311        if ostree_booted()? {
312            return Ok(Self::OstreeBooted);
313        }
314
315        Ok(Self::Other)
316    }
317
318    /// Returns true if this environment requires entering a mount namespace
319    /// before loading storage (to avoid leaving /sysroot writable).
320    pub(crate) fn needs_mount_namespace(&self) -> bool {
321        matches!(self, Self::OstreeBooted | Self::ComposefsBooted(_))
322    }
323}
324
325/// A system can boot via either ostree or composefs; this enum
326/// allows code to handle both cases while maintaining type safety.
327pub(crate) enum BootedStorageKind<'a> {
328    Ostree(BootedOstree<'a>),
329    Composefs(BootedComposefs),
330}
331
332/// Open the physical root (/sysroot) and /run directories for a booted system.
333///
334/// The returned boolean is `true` if `/sysroot` is on a physically read-only
335/// medium (e.g. a live ISO) that cannot be remounted writable.
336///
337/// Note that ostree mounts `/sysroot` read-only by default even on writable
338/// systems and remounts it writable on demand, so being read-only is not by
339/// itself meaningful: we only conclude the system is read-only if a remount
340/// attempt *fails* and the backing block device confirms it.
341fn get_physical_root_and_run() -> Result<(Dir, Dir, bool)> {
342    let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
343        .context("Opening /sysroot")?;
344    let is_ro = sysroot_is_read_only(&d)?;
345    let physical_root = d.open_dir(".").context("Opening /sysroot")?;
346    let run =
347        Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?;
348    Ok((physical_root, run, is_ro))
349}
350
351/// Determine whether `/sysroot` (given as `d`) is on a physically read-only
352/// block device, remounting it read-write as a side effect when necessary.
353///
354/// ostree mounts `/sysroot` read-only by default even on writable systems and
355/// remounts it writable on demand, so the read-only mount flag alone is not
356/// meaningful. The authoritative signal is the backing block device's read-only
357/// flag, which is set for a live ISO (a read-only loop device over an immutable
358/// rootfs image) and propagates from a whole disk to its partitions. We check
359/// that first and avoid even attempting a remount that is bound to fail.
360fn sysroot_is_read_only(d: &Dir) -> Result<bool> {
361    let st = rustix::fs::fstatvfs(d.as_fd())?;
362    if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
363        // Already writable: the common case.
364        return Ok(false);
365    }
366
367    // The backing block device is physically read-only (e.g. a live ISO); there
368    // is no point attempting a remount, and write operations are not possible.
369    if matches!(bootc_blockdev::is_dir_backing_device_ro(d), Ok(Some(true))) {
370        return Ok(true);
371    }
372
373    // The mount is read-only but the device is writable: this is the normal
374    // ostree case where /sysroot is mounted read-only by default. Remount it
375    // writable on demand; a failure here is an unexpected error.
376    open_dir_remount_rw(d, ".".into())?;
377    Ok(false)
378}
379
380/// Where GRUB's configuration and the BLS entries live on a booted composefs
381/// system.
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383enum GrubBootLocation {
384    /// `boot` on the physical root, i.e. `/sysroot/boot`
385    PhysicalRoot,
386    /// A separate boot partition, mounted on `/boot`. It isn't mounted on
387    /// `/sysroot/boot`, which is just an empty directory then.
388    BootMount,
389}
390
391impl GrubBootLocation {
392    /// Decide from the partition type and filesystem type of the filesystem
393    /// mounted on `/boot`, and whether it has a GRUB directory.
394    fn classify(parttype: Option<&str>, fstype: &str, has_grub_dir: bool) -> Self {
395        use bootc_blockdev::dps;
396
397        // The ESP mounted on /boot: GRUB's configuration is in /sysroot/boot
398        let is_esp = match parttype {
399            Some(p) if p.eq_ignore_ascii_case(dps::ESP) => true,
400            Some(p) if p.eq_ignore_ascii_case(dps::XBOOTLDR) => false,
401            // No (or another) DPS type, so go by the filesystem
402            _ => fstype == "vfat",
403        };
404        // Otherwise it's a boot partition (XBOOTLDR or not), which should
405        // have GRUB's configuration, but check just in case
406        if !is_esp && has_grub_dir {
407            Self::BootMount
408        } else {
409            Self::PhysicalRoot
410        }
411    }
412
413    /// The absolute path of the directory in the booted root
414    fn path(self) -> Utf8PathBuf {
415        let parent = match self {
416            Self::PhysicalRoot => "/sysroot",
417            Self::BootMount => "/",
418        };
419        Utf8Path::new(parent).join(BOOT)
420    }
421}
422
423/// Find the directory holding GRUB's configuration and the BLS entries on a
424/// booted system, returning it and its absolute path.
425///
426/// This is `/sysroot/boot`, unless there's a separate boot partition, which
427/// is mounted on `/boot`. How it gets mounted there varies: `bootc install`
428/// adds a `systemd.mount-extra` karg, but other tools may use e.g. an fstab
429/// entry instead. Everything that reads or writes GRUB's boot entries must go
430/// through this, via [`Storage::require_boot_dir`] or
431/// [`Storage::require_grub_boot_path`].
432#[context("Finding boot for Grub")]
433fn get_boot_dir_for_grub(physical_root: &Dir) -> Result<(Dir, Utf8PathBuf)> {
434    // We have this so systemd's boot.automount shouldn't expire until
435    // this function finishes execution as we have a handle to /boot
436    let boot_mount_path = GrubBootLocation::BootMount.path();
437    let boot = Dir::open_ambient_dir(&boot_mount_path, ambient_authority())
438        .with_context(|| format!("Opening {boot_mount_path}"))?;
439
440    let open = |location: GrubBootLocation| -> Result<(Dir, Utf8PathBuf)> {
441        let dir = match location {
442            GrubBootLocation::PhysicalRoot => physical_root
443                .open_dir(BOOT)
444                .context("Opening boot in physical root")?,
445            // Remount /boot if mounted RO
446            GrubBootLocation::BootMount => open_dir_remount_rw(&boot, ".".into())?,
447        };
448        Ok((dir, location.path()))
449    };
450
451    let is_boot_mntpnt = boot
452        .is_mountpoint(".")
453        .with_context(|| format!("Checking if {boot_mount_path} is a mountpoint"))?;
454
455    // /boot is not a mount point for bootloader Grub, so we have to
456    // have stuff in /sysroot/boot
457    if !matches!(is_boot_mntpnt, Some(true)) {
458        return open(GrubBootLocation::PhysicalRoot);
459    }
460
461    // /boot is a mountpoint
462    // Figure out if it's the ESP or a boot partition
463    let mnt_res = run_findmnt(&[], None, Some(boot_mount_path.as_str()))
464        .with_context(|| format!("Finding {boot_mount_path} mount info"))?;
465
466    let mut boot_mount_details: Option<Filesystem> = None;
467
468    for mount in mnt_res.filesystems {
469        if mount.source.starts_with("systemd") {
470            // systemd automount, useless for getting any info
471            continue;
472        }
473
474        if let Some(already_found) = boot_mount_details {
475            // Really shouldn't happen, but for sanity
476            anyhow::bail!(
477                "Found multiple mounts on {boot_mount_path}. Found {}, already had {}",
478                mount.source,
479                already_found.source,
480            );
481        };
482
483        boot_mount_details = Some(mount);
484    }
485
486    let boot_mount_details = boot_mount_details
487        .ok_or_else(|| anyhow::anyhow!("Failed to get mount details for {boot_mount_path}"))?;
488
489    let boot_dev_info = bootc_blockdev::list_dev(&Utf8PathBuf::from(&boot_mount_details.source))?;
490
491    open(GrubBootLocation::classify(
492        boot_dev_info.parttype.as_deref(),
493        &boot_mount_details.fstype,
494        boot.is_dir("grub2"),
495    ))
496}
497
498const LOADER_DEVICE_PART_UUID: &str = "LoaderDevicePartUUID-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f";
499
500/// Find the ESP from which the system was booted
501///
502/// This is a better way to find the correct ESP than just searching
503/// trough all ESPs and returning the first one as we may end up with
504/// the wrong ESP in dual booted systems
505#[context("Finding ESP used for booting")]
506pub(crate) fn find_booted_from_esp(physical_root: &Dir) -> Result<String> {
507    let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?;
508    let all_roots = root_dev.find_all_roots()?;
509
510    let device_uuid = match read_uefi_var(LOADER_DEVICE_PART_UUID) {
511        Ok(u) => u,
512        // Older version of Grub (in c10s) do not include this efivar
513        // We can't rely on ESP being mounted on /boot, /boot/efi as older Grub
514        // doesn't fully support BLS spec
515        Err(EfiError::MissingVar) => {
516            tracing::debug!("Missing {LOADER_DEVICE_PART_UUID}, searching through all ESPs...");
517
518            let all_esps = root_dev
519                .find_colocated_esps()?
520                .ok_or_else(|| anyhow::anyhow!("No ESP found"))?;
521
522            if all_esps.len() == 1 {
523                // If only one ESP, there's no ambiguity
524                return Ok(all_esps.first().unwrap().path());
525            }
526
527            for esp in all_esps {
528                let tmp_mount = mount_esp(&esp.path()).context("Mounting ESP")?;
529
530                // Check if we have bootc owned entries in loader/entries
531                let entries = tmp_mount
532                    .fd
533                    .open_dir_optional(TYPE1_ENT_PATH)
534                    .context("Opening entries dir")?;
535
536                let Some(entries_dir) = entries else {
537                    tracing::debug!("No entries found in ESP {}", esp.path());
538                    continue;
539                };
540
541                for entry in entries_dir
542                    .entries_utf8()
543                    .context("Reading directory entries")?
544                {
545                    let entry = entry?;
546
547                    // We can be fairly sure we put our entries in this ESP
548                    if entry.file_name()?.starts_with(BLS_ENTRY_FILE_PREFIX) {
549                        return Ok(esp.path());
550                    }
551                }
552
553                tracing::debug!("ESP {} not owned by us", esp.path());
554            }
555
556            anyhow::bail!("Failed to find bootc owned ESP");
557        }
558        Err(e) => {
559            anyhow::bail!("Reading {LOADER_DEVICE_PART_UUID}: {e:?}")
560        }
561    };
562
563    tracing::debug!("Found {device_uuid} in {LOADER_DEVICE_PART_UUID}");
564
565    for root in all_roots {
566        let Some(children) = root.children else {
567            continue;
568        };
569
570        for child in children {
571            if child.partuuid.as_ref().is_some_and(|partuuid| {
572                *partuuid.to_ascii_lowercase() == device_uuid.to_ascii_lowercase()
573            }) {
574                return Ok(child.path());
575            }
576        }
577    }
578
579    anyhow::bail!("ESP with uuid {device_uuid} not found")
580}
581
582impl BootedStorage {
583    /// Create a new booted storage accessor for the given environment.
584    ///
585    /// The caller must have already called `prepare_for_write()` if
586    /// `env.needs_mount_namespace()` is true. `esp_access` selects how the
587    /// ESP mount is acquired on composefs systems (see [`EspAccess`]).
588    pub(crate) async fn new(env: Environment, esp_access: EspAccess) -> Result<Option<Self>> {
589        let r = match &env {
590            Environment::ComposefsBooted(cmdline) => {
591                let (physical_root, run, is_ro) = get_physical_root_and_run()?;
592                let is_aboot =
593                    read_boot_type(&physical_root, &cmdline.digest)? == Some(BootType::Aboot);
594                if is_aboot && !is_ro && matches!(esp_access, EspAccess::ReadWrite) {
595                    let kernel_cmdline = std::fs::read_to_string("/proc/cmdline")
596                        .context("Reading kernel command line")?;
597                    aboot::AbootState::open(&physical_root)?.record_booted(
598                        &linux_kernel_cmdline::utf8::Cmdline::from(kernel_cmdline.as_str()),
599                        &cmdline.digest,
600                    )?;
601                }
602                let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
603                if cmdline.allow_missing_fsverity {
604                    composefs.set_insecure()?;
605                }
606                let composefs = Arc::new(composefs);
607
608                let (boot_dir, grub_boot_path, boot_mount, boot_partition) = if is_aboot {
609                    (None, None, None, None)
610                } else {
611                    // Locate ESP by walking up to the root disk(s), and reading
612                    // [`LOADER_DEVICE_PART_UUID`] and matching the UUID with the
613                    // respective ESP device UUID
614                    //
615                    // Both mount variants transparently reuse an already-mounted
616                    // partition when present (e.g. auto-mounted at /boot ro via
617                    // `systemd.mount-extra` in the deployment cmdline).
618                    let bootloader = get_bootloader()?;
619                    let (boot_partition, boot_mount) = BootPartition::mount_booted(
620                        find_booted_from_esp(&physical_root)?.into(),
621                        bootloader,
622                        &cmdline.digest,
623                        |device| match esp_access {
624                            EspAccess::ReadOnly => mount_esp_readonly(device.as_str()),
625                            EspAccess::ReadWrite => mount_esp_writable(device.as_str()),
626                        },
627                    )?;
628
629                    let (boot_dir, grub_boot_path) = match bootloader.kind()? {
630                        // We can have a separate /boot and not /sysroot/boot
631                        BootloaderKind::GRUBClassic => {
632                            let (dir, path) = get_boot_dir_for_grub(&physical_root)?;
633                            (dir, Some(path))
634                        }
635                        BootloaderKind::BLSCompatible => {
636                            (boot_mount.fd.try_clone().context("Cloning fd")?, None)
637                        }
638                    };
639                    (
640                        Some(boot_dir),
641                        grub_boot_path,
642                        Some(boot_mount),
643                        Some(boot_partition),
644                    )
645                };
646
647                let storage = Storage {
648                    physical_root,
649                    physical_root_path: Utf8PathBuf::from("/sysroot"),
650                    is_ro,
651                    run,
652                    boot_dir,
653                    grub_boot_path,
654                    boot_mount,
655                    boot_partition,
656                    ostree: Default::default(),
657                    composefs: OnceCell::from(composefs.clone()),
658                    imgstore: Default::default(),
659                };
660
661                // prepend_custom_prefix is idempotent: it checks has_prefix on each
662                // entry and skips any that already have it, so it's safe to call on
663                // every boot. This handles upgrades from older bootc versions that
664                // lacked the prefix — we can't use meta.json presence as a trigger
665                // because open_upgrade() in the initramfs writes meta.json before
666                // userspace ever runs.
667                if !is_aboot {
668                    let cmdline = composefs_booted()?
669                        .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?;
670                    prepend_custom_prefix(&storage, &cmdline).await?;
671                }
672
673                Some(Self { storage })
674            }
675            Environment::OstreeBooted => {
676                // The caller must have entered a private mount namespace before
677                // calling this function. This is because ostree's sysroot.load() will
678                // remount /sysroot as writable, and we call set_mount_namespace_in_use()
679                // to indicate we're in a mount namespace. Without actually being in a
680                // mount namespace, this would leave the global /sysroot writable.
681                let (physical_root, run, is_ro) = get_physical_root_and_run()?;
682
683                let sysroot = ostree::Sysroot::new_default();
684                // On a read-only sysroot (e.g. a live ISO) we must NOT mark the mount
685                // namespace as in-use, otherwise ostree will attempt to remount /sysroot
686                // read-write on write operations and fail. We also avoid taking the write
687                // lock (which would write a lockfile to the read-only filesystem).
688                let sysroot = if is_ro {
689                    ostree_ext::sysroot::SysrootLock::from_assumed_locked(&sysroot)
690                } else {
691                    sysroot.set_mount_namespace_in_use();
692                    ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?
693                };
694                sysroot.load(gio::Cancellable::NONE)?;
695
696                let storage = Storage {
697                    physical_root,
698                    physical_root_path: Utf8PathBuf::from("/sysroot"),
699                    is_ro,
700                    run,
701                    boot_dir: None,
702                    grub_boot_path: None,
703                    boot_mount: None,
704                    boot_partition: None,
705                    ostree: OnceCell::from(sysroot),
706                    composefs: Default::default(),
707                    imgstore: Default::default(),
708                };
709
710                Some(Self { storage })
711            }
712            // For container or non-bootc environments, there's no storage
713            Environment::Container | Environment::Other => None,
714        };
715        Ok(r)
716    }
717
718    /// Determine the boot storage backend kind.
719    ///
720    /// Returns information about whether the system booted via ostree or composefs,
721    /// along with the relevant sysroot/deployment or repository/cmdline data.
722    pub(crate) fn kind(&self) -> Result<BootedStorageKind<'_>> {
723        if let Some(cmdline) = composefs_booted()? {
724            // SAFETY: This must have been set above in new()
725            let repo = self.composefs.get().unwrap();
726            Ok(BootedStorageKind::Composefs(BootedComposefs {
727                repo: Arc::clone(repo),
728                cmdline,
729            }))
730        } else {
731            // SAFETY: This must have been set above in new()
732            let sysroot = self.ostree.get().unwrap();
733            let deployment = sysroot.require_booted_deployment()?;
734            Ok(BootedStorageKind::Ostree(BootedOstree {
735                sysroot,
736                deployment,
737            }))
738        }
739    }
740}
741
742/// A reference to a physical filesystem root, plus
743/// accessors for the different types of container storage.
744pub(crate) struct Storage {
745    /// Directory holding the physical root
746    pub physical_root: Dir,
747
748    /// Absolute path to the physical root directory.
749    /// This is `/sysroot` on a running system, or the target mount point during install.
750    pub physical_root_path: Utf8PathBuf,
751
752    /// True if the physical root (`/sysroot`) is on a physically read-only
753    /// block device (e.g. a live ISO) and cannot be made writable.
754    pub(crate) is_ro: bool,
755
756    /// The 'boot' directory, useful and `Some` only for composefs systems
757    /// For grub booted systems, this points to `/sysroot/boot`, or to `/boot`
758    /// if that's a separate boot partition
759    /// For systemd booted systems, this points to `boot_partition`
760    pub boot_dir: Option<Dir>,
761
762    /// The absolute path of `boot_dir` on composefs systems booted with grub
763    grub_boot_path: Option<Utf8PathBuf>,
764
765    /// `boot_partition` mounted at a tmp location
766    pub boot_mount: Option<TempMount>,
767
768    /// The partition holding the UKIs and Type #1 entries on composefs
769    /// systems: the ESP, or the XBOOTLDR partition next to it
770    boot_partition: Option<BootPartition>,
771
772    /// Our runtime state
773    run: Dir,
774
775    /// The OSTree storage
776    ostree: OnceCell<SysrootLock>,
777    /// The composefs storage
778    composefs: OnceCell<Arc<ComposefsRepository>>,
779    /// The containers-image storage used for LBIs
780    imgstore: OnceCell<CStorage>,
781}
782
783/// Cached image status data used for optimization.
784///
785/// This stores the current image status and any cached update information
786/// to avoid redundant fetches during status operations.
787#[derive(Default)]
788pub(crate) struct CachedImageStatus {
789    pub image: Option<ImageStatus>,
790    pub cached_update: Option<ImageStatus>,
791}
792
793impl Storage {
794    #[cfg(test)]
795    pub(crate) fn new_composefs_for_test(physical_root: &Dir, run: &Dir) -> Result<Self> {
796        Ok(Self {
797            physical_root: physical_root.try_clone()?,
798            physical_root_path: Utf8PathBuf::from("/sysroot"),
799            is_ro: false,
800            boot_dir: None,
801            grub_boot_path: None,
802            boot_mount: None,
803            boot_partition: None,
804            run: run.try_clone()?,
805            ostree: Default::default(),
806            composefs: Default::default(),
807            imgstore: Default::default(),
808        })
809    }
810
811    /// Create a new storage accessor from an existing ostree sysroot.
812    ///
813    /// This is used for non-booted scenarios (e.g., `bootc install`) where
814    /// we're operating on a target filesystem rather than the running system.
815    pub fn new_ostree(sysroot: SysrootLock, run: &Dir) -> Result<Self> {
816        let run = run.try_clone()?;
817
818        // ostree has historically always relied on
819        // having ostree -> sysroot/ostree as a symlink in the image to
820        // make it so that code doesn't need to distinguish between booted
821        // vs offline target. The ostree code all just looks at the ostree/
822        // directory, and will follow the link in the booted case.
823        //
824        // For composefs we aren't going to do a similar thing, so here
825        // we need to explicitly distinguish the two and the storage
826        // here hence holds a reference to the physical root.
827        let ostree_sysroot_dir = crate::utils::sysroot_dir(&sysroot)?;
828        let (physical_root, physical_root_path) = if sysroot.is_booted() {
829            (
830                ostree_sysroot_dir.open_dir(SYSROOT)?,
831                Utf8PathBuf::from("/sysroot"),
832            )
833        } else {
834            // For non-booted case (install), get the path from the sysroot
835            let path = sysroot.path();
836            let path_str = path.parse_name().to_string();
837            let path = Utf8PathBuf::from(path_str);
838            (ostree_sysroot_dir, path)
839        };
840
841        let ostree_cell = OnceCell::new();
842        let _ = ostree_cell.set(sysroot);
843
844        Ok(Self {
845            physical_root,
846            physical_root_path,
847            is_ro: false,
848            run,
849            boot_dir: None,
850            grub_boot_path: None,
851            boot_mount: None,
852            boot_partition: None,
853            ostree: ostree_cell,
854            composefs: Default::default(),
855            imgstore: Default::default(),
856        })
857    }
858
859    /// Ensure the storage is writable, erroring with a clear message if the
860    /// underlying `/sysroot` is on a read-only block device (e.g. a live ISO).
861    pub(crate) fn require_writable(&self) -> Result<()> {
862        anyhow::ensure!(
863            !self.is_ro,
864            "Cannot perform this operation: /sysroot is on a read-only block device (e.g. a live ISO)"
865        );
866        Ok(())
867    }
868
869    /// Returns `boot_dir` if it exists
870    pub(crate) fn require_boot_dir(&self) -> Result<&Dir> {
871        self.boot_dir
872            .as_ref()
873            .ok_or_else(|| anyhow::anyhow!("Boot dir not found"))
874    }
875
876    /// Returns the absolute path of `boot_dir` on a composefs system booted
877    /// with grub, for code that works with paths rather than directory fds:
878    /// `/boot` if it's a separate boot partition, `/sysroot/boot` otherwise
879    pub(crate) fn require_grub_boot_path(&self) -> Result<&Utf8Path> {
880        self.grub_boot_path
881            .as_deref()
882            .ok_or_else(|| anyhow::anyhow!("Grub boot path not found"))
883    }
884
885    /// Returns the mounted `boot_partition` if it exists
886    pub(crate) fn require_boot_mount(&self) -> Result<&TempMount> {
887        self.boot_mount
888            .as_ref()
889            .ok_or_else(|| anyhow::anyhow!("Boot partition not mounted"))
890    }
891
892    /// Returns the partition holding the UKIs and Type #1 entries
893    pub(crate) fn require_boot_partition(&self) -> Result<&BootPartition> {
894        self.boot_partition
895            .as_ref()
896            .ok_or_else(|| anyhow::anyhow!("Boot partition not found"))
897    }
898
899    /// Returns the Directory where the Type1 boot binaries are stored
900    /// The grub boot directory (see `require_boot_dir`) for Grub, and ESP/EFI/Linux for systemd-boot
901    pub(crate) fn bls_boot_binaries_dir(&self) -> Result<Dir> {
902        let boot_dir = self.require_boot_dir()?;
903
904        // boot dir in case of systemd-boot points to the ESP, but we store
905        // the actual binaries inside ESP/EFI/Linux
906        let boot_dir = match get_bootloader()?.kind()? {
907            BootloaderKind::GRUBClassic => boot_dir.try_clone()?,
908            BootloaderKind::BLSCompatible => {
909                let boot_dir = boot_dir
910                    .open_dir(EFI_LINUX)
911                    .with_context(|| format!("Opening {EFI_LINUX}"))?;
912
913                boot_dir
914            }
915        };
916
917        Ok(boot_dir)
918    }
919
920    /// Access the underlying ostree repository
921    pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> {
922        self.ostree
923            .get()
924            .ok_or_else(|| anyhow::anyhow!("OSTree storage not initialized"))
925    }
926
927    /// Get a cloned reference to the ostree sysroot.
928    ///
929    /// This is used when code needs an owned `ostree::Sysroot` rather than
930    /// a reference to the `SysrootLock`.
931    pub(crate) fn get_ostree_cloned(&self) -> Result<ostree::Sysroot> {
932        let r = self.get_ostree()?;
933        Ok((*r).clone())
934    }
935
936    /// Access the image storage; will automatically initialize it if necessary.
937    ///
938    /// Works on both ostree and composefs-only systems.  On ostree the
939    /// SELinux policy is loaded from the booted deployment; on composefs
940    /// (where ostree isn't initialized) we fall back to the host root policy.
941    pub(crate) fn get_ensure_imgstore(&self) -> Result<&CStorage> {
942        if let Some(imgstore) = self.imgstore.get() {
943            return Ok(imgstore);
944        }
945
946        let (sysroot_dir, sepolicy) = if let Ok(ostree) = self.get_ostree() {
947            let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
948            let sepolicy = if ostree.booted_deployment().is_none() {
949                tracing::trace!("falling back to container root's selinux policy");
950                let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
951                lsm::new_sepolicy_at(&container_root)?
952            } else {
953                tracing::trace!("loading sepolicy from booted ostree deployment");
954                let dep = ostree.booted_deployment().unwrap();
955                let dep_fs = deployment_fd(ostree, &dep)?;
956                lsm::new_sepolicy_at(&dep_fs)?
957            };
958            (sysroot_dir, sepolicy)
959        } else {
960            // Composefs-only: ostree is not initialized. Use the physical
961            // root directly and load SELinux policy from the host root.
962            let sysroot_dir = self.physical_root.try_clone()?;
963            let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
964            let sepolicy = lsm::new_sepolicy_at(&root)?;
965            (sysroot_dir, sepolicy)
966        };
967
968        tracing::trace!("sepolicy in get_ensure_imgstore: {sepolicy:?}");
969
970        let imgstore = CStorage::create(&sysroot_dir, &self.run, sepolicy.as_ref())?;
971        Ok(self.imgstore.get_or_init(|| imgstore))
972    }
973
974    /// Ensure the image storage is properly SELinux-labeled. This should be
975    /// called after all image pulls are complete.
976    pub(crate) fn ensure_imgstore_labeled(&self) -> Result<()> {
977        if let Some(imgstore) = self.imgstore.get() {
978            imgstore.ensure_labeled()?;
979        }
980        Ok(())
981    }
982
983    /// Access the composefs repository; will automatically initialize it if necessary.
984    ///
985    /// This lazily opens the composefs repository, creating the directory if needed
986    /// and bootstrapping verity settings from the ostree configuration.
987    ///
988    /// If the repository already exists on disk, it is opened as-is, preserving
989    /// whatever EROFS format version it was created with (e.g. V2 from an older
990    /// composefs-rs).  A fresh repository is only initialized when no `meta.json`
991    /// is found, using the current default format version from composefs-rs.
992    pub(crate) fn get_ensure_composefs(&self) -> Result<Arc<ComposefsRepository>> {
993        if let Some(composefs) = self.composefs.get() {
994            return Ok(Arc::clone(composefs));
995        }
996
997        ensure_composefs_dir(&self.physical_root)?;
998
999        // Bootstrap verity off of the ostree state. In practice this means disabled by
1000        // default right now.
1001        let ostree = self.get_ostree()?;
1002        let ostree_repo = &ostree.repo();
1003        let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?;
1004
1005        // First, try to open an existing repository.  This respects whatever
1006        // EROFS format version (V1 or V2) was persisted in meta.json, avoiding
1007        // the "already initialized with different configuration" error that
1008        // occurs when the composefs-rs default format version changes between
1009        // bootc builds (e.g. V2 → V1 after composefs-rs PR #330).
1010        let composefs_dir = self.physical_root.open_dir(COMPOSEFS)?;
1011        let composefs = match ComposefsRepository::open_path(&composefs_dir, ".") {
1012            Ok(mut repo) => {
1013                if !ostree_verity.enabled {
1014                    repo.set_insecure()?;
1015                }
1016                repo
1017            }
1018            Err(RepositoryOpenError::MetadataMissing) => {
1019                // No meta.json — this is a fresh directory.  Existing repositories
1020                // above retain their recorded format configuration.
1021                let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
1022                set_dual_erofs_formats(&mut config);
1023                let config = if ostree_verity.enabled {
1024                    config
1025                } else {
1026                    config.set_insecure()
1027                };
1028                let (repo, _created) = ComposefsRepository::init_path(composefs_dir, ".", config)?;
1029                repo
1030            }
1031            Err(RepositoryOpenError::OldFormatRepository) => {
1032                // Pre-meta.json repository — use the upgrade path that infers
1033                // the algorithm and writes meta.json.
1034                let (mut repo, _upgraded) = ComposefsRepository::open_upgrade(&composefs_dir, ".")?;
1035                if !ostree_verity.enabled {
1036                    repo.set_insecure()?;
1037                }
1038                repo
1039            }
1040            Err(e) => {
1041                return Err(anyhow::Error::new(e).context("Opening composefs repository"));
1042            }
1043        };
1044        let composefs = Arc::new(composefs);
1045        let r = Arc::clone(self.composefs.get_or_init(|| composefs));
1046        Ok(r)
1047    }
1048
1049    /// Update the mtime on the storage root directory.
1050    ///
1051    /// This touches `ostree/bootc` (or its symlink target on composefs
1052    /// systems) so that `bootc-status-updated.path` fires.
1053    #[context("Updating storage root mtime")]
1054    pub(crate) fn update_mtime(&self) -> Result<()> {
1055        // On composefs-only systems ostree is not initialized, so fall
1056        // back to the physical root directly.
1057        let sysroot_dir = if let Ok(ostree) = self.get_ostree() {
1058            crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")?
1059        } else {
1060            self.physical_root.try_clone()?
1061        };
1062
1063        sysroot_dir
1064            .update_timestamps(std::path::Path::new(BOOTC_ROOT))
1065            .context("update_timestamps")
1066    }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    /// The raw mode returned by metadata includes file type bits (S_IFDIR,
1074    /// etc.) in addition to permission bits. This constant masks to only
1075    /// the permission bits (owner/group/other rwx).
1076    const PERMS: Mode = Mode::from_raw_mode(0o777);
1077
1078    #[test]
1079    fn test_ensure_composefs_dir_mode() -> Result<()> {
1080        use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
1081
1082        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1083
1084        let assert_mode = || -> Result<()> {
1085            let perms = td.metadata(COMPOSEFS)?.permissions();
1086            let mode = Mode::from_raw_mode(perms.mode());
1087            assert_eq!(mode & PERMS, COMPOSEFS_MODE);
1088            Ok(())
1089        };
1090
1091        ensure_composefs_dir(&td)?;
1092        assert_mode()?;
1093
1094        // Calling again should be a no-op (ensure is idempotent)
1095        ensure_composefs_dir(&td)?;
1096        assert_mode()?;
1097
1098        Ok(())
1099    }
1100
1101    #[test]
1102    fn test_ensure_composefs_dir_fixes_existing() -> Result<()> {
1103        use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
1104
1105        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1106
1107        // Create with overly permissive mode (simulating old bootc behavior)
1108        let mut db = DirBuilder::new();
1109        db.mode(0o755);
1110        td.create_dir_with(COMPOSEFS, &db)?;
1111
1112        // Verify it starts with wrong permissions
1113        let perms = td.metadata(COMPOSEFS)?.permissions();
1114        let mode = Mode::from_raw_mode(perms.mode());
1115        assert_eq!(mode & PERMS, Mode::from_raw_mode(0o755));
1116
1117        // ensure_composefs_dir should fix the permissions
1118        ensure_composefs_dir(&td)?;
1119
1120        let perms = td.metadata(COMPOSEFS)?.permissions();
1121        let mode = Mode::from_raw_mode(perms.mode());
1122        assert_eq!(mode & PERMS, COMPOSEFS_MODE);
1123
1124        Ok(())
1125    }
1126
1127    #[test]
1128    fn test_grub_boot_location_classify() {
1129        use GrubBootLocation::*;
1130        use bootc_blockdev::dps;
1131
1132        const LINUX_DATA: &str = "0fc63daf-8483-4772-8e79-3d69d8477de4";
1133        let upper_xbootldr = dps::XBOOTLDR.to_ascii_uppercase();
1134
1135        // (parttype, fstype, has grub dir, expected)
1136        let cases = [
1137            // The ESP on /boot never holds GRUB's configuration
1138            (Some(dps::ESP), "vfat", true, PhysicalRoot),
1139            (Some(dps::ESP), "vfat", false, PhysicalRoot),
1140            // XBOOTLDR, whatever its filesystem, if it has GRUB's configuration
1141            (Some(dps::XBOOTLDR), "ext4", true, BootMount),
1142            (Some(dps::XBOOTLDR), "vfat", true, BootMount),
1143            (Some(dps::XBOOTLDR), "ext4", false, PhysicalRoot),
1144            (Some(upper_xbootldr.as_str()), "xfs", true, BootMount),
1145            // No DPS type: go by the filesystem
1146            (Some(LINUX_DATA), "ext4", true, BootMount),
1147            (Some(LINUX_DATA), "vfat", true, PhysicalRoot),
1148            (None, "xfs", true, BootMount),
1149            (None, "btrfs", false, PhysicalRoot),
1150            (None, "vfat", true, PhysicalRoot),
1151        ];
1152        for (parttype, fstype, has_grub_dir, expected) in cases {
1153            assert_eq!(
1154                GrubBootLocation::classify(parttype, fstype, has_grub_dir),
1155                expected,
1156                "{parttype:?} {fstype} grub dir: {has_grub_dir}"
1157            );
1158        }
1159
1160        assert_eq!(PhysicalRoot.path(), "/sysroot/boot");
1161        assert_eq!(BootMount.path(), "/boot");
1162    }
1163}