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 camino::Utf8PathBuf;
100use cap_std_ext::cap_std;
101use cap_std_ext::cap_std::fs::{
102    Dir, DirBuilder, DirBuilderExt as _, Permissions, PermissionsExt as _,
103};
104use cap_std_ext::dirext::CapStdExtDirExt;
105use fn_error_context::context;
106
107use ostree_ext::container_utils::ostree_booted;
108use ostree_ext::prelude::FileExt;
109use ostree_ext::sysroot::SysrootLock;
110use ostree_ext::{gio, ostree};
111use rustix::{fd::AsFd, fs::Mode, fs::StatVfsMountFlags};
112
113use composefs::fsverity::Sha512HashValue;
114use composefs::repository::{RepositoryConfig, RepositoryOpenError};
115use composefs_ctl::composefs;
116
117use crate::bootc_composefs::backwards_compat::bcompat_boot::prepend_custom_prefix;
118use crate::bootc_composefs::boot::{EFI_LINUX, mount_esp_readonly, mount_esp_writable};
119use crate::bootc_composefs::status::{ComposefsCmdline, composefs_booted, get_bootloader};
120use crate::lsm;
121use crate::podstorage::CStorage;
122use crate::spec::{BootloaderKind, ImageStatus};
123use crate::utils::{deployment_fd, open_dir_remount_rw};
124
125/// See <https://github.com/containers/composefs-rs/issues/159>
126pub type ComposefsRepository = composefs::repository::Repository<Sha512HashValue>;
127
128/// Path to the physical root
129pub const SYSROOT: &str = "sysroot";
130
131/// The toplevel composefs directory path
132pub const COMPOSEFS: &str = "composefs";
133
134/// The mode for the composefs directory; this is intentionally restrictive
135/// to avoid leaking information.
136pub(crate) const COMPOSEFS_MODE: Mode = Mode::from_raw_mode(0o700);
137
138/// Ensure the composefs directory exists in the given physical root
139/// with the correct permissions (mode 0700).
140pub(crate) fn ensure_composefs_dir(physical_root: &Dir) -> Result<()> {
141    let mut db = DirBuilder::new();
142    db.mode(COMPOSEFS_MODE.as_raw_mode());
143    physical_root
144        .ensure_dir_with(COMPOSEFS, &db)
145        .context("Creating composefs directory")?;
146    // Always update permissions, in case the directory pre-existed
147    // with incorrect mode (e.g. from an older version of bootc).
148    physical_root
149        .set_permissions(
150            COMPOSEFS,
151            Permissions::from_mode(COMPOSEFS_MODE.as_raw_mode()),
152        )
153        .context("Setting composefs directory permissions")?;
154    Ok(())
155}
156
157/// The path to the bootc root directory, relative to the physical
158/// system root.  On ostree systems this is a real directory; on composefs
159/// systems it is a symlink to `../composefs/bootc` (see
160/// [`ensure_composefs_bootc_link`]).
161pub(crate) const BOOTC_ROOT: &str = "ostree/bootc";
162
163/// The "real" bootc root for composefs-native systems, relative to the
164/// physical system root.
165pub(crate) const COMPOSEFS_BOOTC_ROOT: &str = "composefs/bootc";
166
167/// On a composefs install the containers-storage lives under
168/// `composefs/bootc/storage`.  To keep the rest of the code (and the
169/// `/usr/lib/bootc/storage` symlink which points through `ostree/bootc`)
170/// working, we create:
171///
172///   `ostree/bootc -> ../composefs/bootc`
173///
174/// This function is idempotent.
175pub(crate) fn ensure_composefs_bootc_link(physical_root: &Dir) -> Result<()> {
176    // Ensure the real directory exists
177    physical_root
178        .create_dir_all(COMPOSEFS_BOOTC_ROOT)
179        .with_context(|| format!("Creating {COMPOSEFS_BOOTC_ROOT}"))?;
180
181    // Create the `ostree/` parent if needed (it won't exist on a pure
182    // composefs install that never touched ostree).
183    physical_root
184        .create_dir_all("ostree")
185        .context("Creating ostree directory")?;
186
187    // If ostree/bootc already exists as a real directory (e.g. from an
188    // older install or from the ostree path), leave it alone — this
189    // function is only for fresh composefs installs.
190    match physical_root.symlink_metadata(BOOTC_ROOT) {
191        Ok(meta) if meta.is_symlink() => {
192            // Already a symlink — nothing to do
193            return Ok(());
194        }
195        Ok(_meta) => {
196            // It's a real directory.  This shouldn't happen during a fresh
197            // composefs install, but if it does just leave it.
198            tracing::warn!(
199                "{BOOTC_ROOT} already exists as a directory, not replacing with symlink"
200            );
201            return Ok(());
202        }
203        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
204            // Good — doesn't exist yet, we'll create the symlink
205        }
206        Err(e) => return Err(e).context(format!("Querying {BOOTC_ROOT}")),
207    }
208
209    physical_root
210        .symlink_contents(format!("../{COMPOSEFS_BOOTC_ROOT}"), BOOTC_ROOT)
211        .with_context(|| format!("Creating {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT} symlink"))?;
212
213    tracing::info!("Created {BOOTC_ROOT} -> ../{COMPOSEFS_BOOTC_ROOT}");
214    Ok(())
215}
216
217/// Storage accessor for a booted system.
218///
219/// This wraps [`Storage`] and can determine whether the system is booted
220/// via ostree or composefs, providing a unified interface for both.
221pub(crate) struct BootedStorage {
222    pub(crate) storage: Storage,
223}
224
225impl Deref for BootedStorage {
226    type Target = Storage;
227
228    fn deref(&self) -> &Self::Target {
229        &self.storage
230    }
231}
232
233/// Represents an ostree-based boot environment
234pub struct BootedOstree<'a> {
235    pub(crate) sysroot: &'a SysrootLock,
236    pub(crate) deployment: ostree::Deployment,
237}
238
239impl<'a> BootedOstree<'a> {
240    /// Get the ostree repository
241    pub(crate) fn repo(&self) -> ostree::Repo {
242        self.sysroot.repo()
243    }
244
245    /// Get the stateroot name
246    pub(crate) fn stateroot(&self) -> ostree::glib::GString {
247        self.deployment.osname()
248    }
249}
250
251/// Represents a composefs-based boot environment
252pub struct BootedComposefs {
253    pub repo: Arc<ComposefsRepository>,
254    pub cmdline: &'static ComposefsCmdline,
255}
256
257/// Discriminated union representing the boot storage backend.
258///
259/// The runtime environment in which bootc is executing.
260pub(crate) enum Environment {
261    /// System booted via ostree
262    OstreeBooted,
263    /// System booted via composefs
264    ComposefsBooted(ComposefsCmdline),
265    /// Running in a container
266    Container,
267    /// Other (not booted via bootc)
268    Other,
269}
270
271/// Whether a `BootedStorage` caller intends to write to the ESP.
272///
273/// Only meaningful for `Environment::ComposefsBooted`; ignored elsewhere.
274/// Read-only callers (e.g. `bootc status`) pass `ReadOnly` so a pre-mounted
275/// ro ESP can be cloned as-is; write callers (e.g. `bootc upgrade`) pass
276/// `ReadWrite` so a pre-mounted ro ESP is remounted rw in the private
277/// mount namespace.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub(crate) enum EspAccess {
280    ReadOnly,
281    ReadWrite,
282}
283
284impl Environment {
285    /// Detect the current runtime environment.
286    pub(crate) fn detect() -> Result<Self> {
287        if ostree_ext::container_utils::running_in_container() {
288            return Ok(Self::Container);
289        }
290
291        if let Some(cmdline) = composefs_booted()? {
292            return Ok(Self::ComposefsBooted(cmdline.clone()));
293        }
294
295        if ostree_booted()? {
296            return Ok(Self::OstreeBooted);
297        }
298
299        Ok(Self::Other)
300    }
301
302    /// Returns true if this environment requires entering a mount namespace
303    /// before loading storage (to avoid leaving /sysroot writable).
304    pub(crate) fn needs_mount_namespace(&self) -> bool {
305        matches!(self, Self::OstreeBooted | Self::ComposefsBooted(_))
306    }
307}
308
309/// A system can boot via either ostree or composefs; this enum
310/// allows code to handle both cases while maintaining type safety.
311pub(crate) enum BootedStorageKind<'a> {
312    Ostree(BootedOstree<'a>),
313    Composefs(BootedComposefs),
314}
315
316/// Open the physical root (/sysroot) and /run directories for a booted system.
317///
318/// The returned boolean is `true` if `/sysroot` is on a physically read-only
319/// medium (e.g. a live ISO) that cannot be remounted writable.
320///
321/// Note that ostree mounts `/sysroot` read-only by default even on writable
322/// systems and remounts it writable on demand, so being read-only is not by
323/// itself meaningful: we only conclude the system is read-only if a remount
324/// attempt *fails* and the backing block device confirms it.
325fn get_physical_root_and_run() -> Result<(Dir, Dir, bool)> {
326    let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
327        .context("Opening /sysroot")?;
328    let is_ro = sysroot_is_read_only(&d)?;
329    let physical_root = d.open_dir(".").context("Opening /sysroot")?;
330    let run =
331        Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?;
332    Ok((physical_root, run, is_ro))
333}
334
335/// Determine whether `/sysroot` (given as `d`) is on a physically read-only
336/// block device, remounting it read-write as a side effect when necessary.
337///
338/// ostree mounts `/sysroot` read-only by default even on writable systems and
339/// remounts it writable on demand, so the read-only mount flag alone is not
340/// meaningful. The authoritative signal is the backing block device's read-only
341/// flag, which is set for a live ISO (a read-only loop device over an immutable
342/// rootfs image) and propagates from a whole disk to its partitions. We check
343/// that first and avoid even attempting a remount that is bound to fail.
344fn sysroot_is_read_only(d: &Dir) -> Result<bool> {
345    let st = rustix::fs::fstatvfs(d.as_fd())?;
346    if !st.f_flag.contains(StatVfsMountFlags::RDONLY) {
347        // Already writable: the common case.
348        return Ok(false);
349    }
350
351    // The backing block device is physically read-only (e.g. a live ISO); there
352    // is no point attempting a remount, and write operations are not possible.
353    if matches!(bootc_blockdev::is_dir_backing_device_ro(d), Ok(Some(true))) {
354        return Ok(true);
355    }
356
357    // The mount is read-only but the device is writable: this is the normal
358    // ostree case where /sysroot is mounted read-only by default. Remount it
359    // writable on demand; a failure here is an unexpected error.
360    open_dir_remount_rw(d, ".".into())?;
361    Ok(false)
362}
363
364impl BootedStorage {
365    /// Create a new booted storage accessor for the given environment.
366    ///
367    /// The caller must have already called `prepare_for_write()` if
368    /// `env.needs_mount_namespace()` is true. `esp_access` selects how the
369    /// ESP mount is acquired on composefs systems (see [`EspAccess`]).
370    pub(crate) async fn new(env: Environment, esp_access: EspAccess) -> Result<Option<Self>> {
371        let r = match &env {
372            Environment::ComposefsBooted(cmdline) => {
373                let (physical_root, run, is_ro) = get_physical_root_and_run()?;
374                let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
375                if cmdline.allow_missing_fsverity {
376                    composefs.set_insecure();
377                }
378                let composefs = Arc::new(composefs);
379
380                // Locate ESP by walking up to the root disk(s). Both mount
381                // variants transparently reuse an already-mounted ESP when
382                // present (e.g. auto-mounted at /boot ro via
383                // `systemd.mount-extra` in the deployment cmdline).
384                let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?;
385                let esp_dev = root_dev.find_first_colocated_esp()?;
386                let esp_path = esp_dev.path();
387                let esp_mount = match esp_access {
388                    EspAccess::ReadOnly => mount_esp_readonly(&esp_path)?,
389                    EspAccess::ReadWrite => mount_esp_writable(&esp_path)?,
390                };
391
392                let boot_dir = match get_bootloader()?.kind()? {
393                    BootloaderKind::GRUBClassic => {
394                        physical_root.open_dir("boot").context("Opening boot")?
395                    }
396                    // NOTE: Handle XBOOTLDR partitions here if and when we use it
397                    BootloaderKind::BLSCompatible => {
398                        esp_mount.fd.try_clone().context("Cloning fd")?
399                    }
400                };
401
402                let storage = Storage {
403                    physical_root,
404                    physical_root_path: Utf8PathBuf::from("/sysroot"),
405                    is_ro,
406                    run,
407                    boot_dir: Some(boot_dir),
408                    esp: Some(esp_mount),
409                    ostree: Default::default(),
410                    composefs: OnceCell::from(composefs.clone()),
411                    imgstore: Default::default(),
412                };
413
414                // prepend_custom_prefix is idempotent: it checks has_prefix on each
415                // entry and skips any that already have it, so it's safe to call on
416                // every boot. This handles upgrades from older bootc versions that
417                // lacked the prefix — we can't use meta.json presence as a trigger
418                // because open_upgrade() in the initramfs writes meta.json before
419                // userspace ever runs.
420                let cmdline = composefs_booted()?
421                    .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?;
422                prepend_custom_prefix(&storage, &cmdline).await?;
423
424                Some(Self { storage })
425            }
426            Environment::OstreeBooted => {
427                // The caller must have entered a private mount namespace before
428                // calling this function. This is because ostree's sysroot.load() will
429                // remount /sysroot as writable, and we call set_mount_namespace_in_use()
430                // to indicate we're in a mount namespace. Without actually being in a
431                // mount namespace, this would leave the global /sysroot writable.
432                let (physical_root, run, is_ro) = get_physical_root_and_run()?;
433
434                let sysroot = ostree::Sysroot::new_default();
435                // On a read-only sysroot (e.g. a live ISO) we must NOT mark the mount
436                // namespace as in-use, otherwise ostree will attempt to remount /sysroot
437                // read-write on write operations and fail. We also avoid taking the write
438                // lock (which would write a lockfile to the read-only filesystem).
439                let sysroot = if is_ro {
440                    ostree_ext::sysroot::SysrootLock::from_assumed_locked(&sysroot)
441                } else {
442                    sysroot.set_mount_namespace_in_use();
443                    ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?
444                };
445                sysroot.load(gio::Cancellable::NONE)?;
446
447                let storage = Storage {
448                    physical_root,
449                    physical_root_path: Utf8PathBuf::from("/sysroot"),
450                    is_ro,
451                    run,
452                    boot_dir: None,
453                    esp: None,
454                    ostree: OnceCell::from(sysroot),
455                    composefs: Default::default(),
456                    imgstore: Default::default(),
457                };
458
459                Some(Self { storage })
460            }
461            // For container or non-bootc environments, there's no storage
462            Environment::Container | Environment::Other => None,
463        };
464        Ok(r)
465    }
466
467    /// Determine the boot storage backend kind.
468    ///
469    /// Returns information about whether the system booted via ostree or composefs,
470    /// along with the relevant sysroot/deployment or repository/cmdline data.
471    pub(crate) fn kind(&self) -> Result<BootedStorageKind<'_>> {
472        if let Some(cmdline) = composefs_booted()? {
473            // SAFETY: This must have been set above in new()
474            let repo = self.composefs.get().unwrap();
475            Ok(BootedStorageKind::Composefs(BootedComposefs {
476                repo: Arc::clone(repo),
477                cmdline,
478            }))
479        } else {
480            // SAFETY: This must have been set above in new()
481            let sysroot = self.ostree.get().unwrap();
482            let deployment = sysroot.require_booted_deployment()?;
483            Ok(BootedStorageKind::Ostree(BootedOstree {
484                sysroot,
485                deployment,
486            }))
487        }
488    }
489}
490
491/// A reference to a physical filesystem root, plus
492/// accessors for the different types of container storage.
493pub(crate) struct Storage {
494    /// Directory holding the physical root
495    pub physical_root: Dir,
496
497    /// Absolute path to the physical root directory.
498    /// This is `/sysroot` on a running system, or the target mount point during install.
499    pub physical_root_path: Utf8PathBuf,
500
501    /// True if the physical root (`/sysroot`) is on a physically read-only
502    /// block device (e.g. a live ISO) and cannot be made writable.
503    pub(crate) is_ro: bool,
504
505    /// The 'boot' directory, useful and `Some` only for composefs systems
506    /// For grub booted systems, this points to `/sysroot/boot`
507    /// For systemd booted systems, this points to the ESP
508    pub boot_dir: Option<Dir>,
509
510    /// The ESP mounted at a tmp location
511    pub esp: Option<TempMount>,
512
513    /// Our runtime state
514    run: Dir,
515
516    /// The OSTree storage
517    ostree: OnceCell<SysrootLock>,
518    /// The composefs storage
519    composefs: OnceCell<Arc<ComposefsRepository>>,
520    /// The containers-image storage used for LBIs
521    imgstore: OnceCell<CStorage>,
522}
523
524/// Cached image status data used for optimization.
525///
526/// This stores the current image status and any cached update information
527/// to avoid redundant fetches during status operations.
528#[derive(Default)]
529pub(crate) struct CachedImageStatus {
530    pub image: Option<ImageStatus>,
531    pub cached_update: Option<ImageStatus>,
532}
533
534impl Storage {
535    /// Create a new storage accessor from an existing ostree sysroot.
536    ///
537    /// This is used for non-booted scenarios (e.g., `bootc install`) where
538    /// we're operating on a target filesystem rather than the running system.
539    pub fn new_ostree(sysroot: SysrootLock, run: &Dir) -> Result<Self> {
540        let run = run.try_clone()?;
541
542        // ostree has historically always relied on
543        // having ostree -> sysroot/ostree as a symlink in the image to
544        // make it so that code doesn't need to distinguish between booted
545        // vs offline target. The ostree code all just looks at the ostree/
546        // directory, and will follow the link in the booted case.
547        //
548        // For composefs we aren't going to do a similar thing, so here
549        // we need to explicitly distinguish the two and the storage
550        // here hence holds a reference to the physical root.
551        let ostree_sysroot_dir = crate::utils::sysroot_dir(&sysroot)?;
552        let (physical_root, physical_root_path) = if sysroot.is_booted() {
553            (
554                ostree_sysroot_dir.open_dir(SYSROOT)?,
555                Utf8PathBuf::from("/sysroot"),
556            )
557        } else {
558            // For non-booted case (install), get the path from the sysroot
559            let path = sysroot.path();
560            let path_str = path.parse_name().to_string();
561            let path = Utf8PathBuf::from(path_str);
562            (ostree_sysroot_dir, path)
563        };
564
565        let ostree_cell = OnceCell::new();
566        let _ = ostree_cell.set(sysroot);
567
568        Ok(Self {
569            physical_root,
570            physical_root_path,
571            is_ro: false,
572            run,
573            boot_dir: None,
574            esp: None,
575            ostree: ostree_cell,
576            composefs: Default::default(),
577            imgstore: Default::default(),
578        })
579    }
580
581    /// Ensure the storage is writable, erroring with a clear message if the
582    /// underlying `/sysroot` is on a read-only block device (e.g. a live ISO).
583    pub(crate) fn require_writable(&self) -> Result<()> {
584        anyhow::ensure!(
585            !self.is_ro,
586            "Cannot perform this operation: /sysroot is on a read-only block device (e.g. a live ISO)"
587        );
588        Ok(())
589    }
590
591    /// Returns `boot_dir` if it exists
592    pub(crate) fn require_boot_dir(&self) -> Result<&Dir> {
593        self.boot_dir
594            .as_ref()
595            .ok_or_else(|| anyhow::anyhow!("Boot dir not found"))
596    }
597
598    /// Returns the mounted `esp` if it exists
599    pub(crate) fn require_esp(&self) -> Result<&TempMount> {
600        self.esp
601            .as_ref()
602            .ok_or_else(|| anyhow::anyhow!("ESP not found"))
603    }
604
605    /// Returns the Directory where the Type1 boot binaries are stored
606    /// `/sysroot/boot` for Grub, and ESP/EFI/Linux for systemd-boot
607    pub(crate) fn bls_boot_binaries_dir(&self) -> Result<Dir> {
608        let boot_dir = self.require_boot_dir()?;
609
610        // boot dir in case of systemd-boot points to the ESP, but we store
611        // the actual binaries inside ESP/EFI/Linux
612        let boot_dir = match get_bootloader()?.kind()? {
613            BootloaderKind::GRUBClassic => boot_dir.try_clone()?,
614            BootloaderKind::BLSCompatible => {
615                let boot_dir = boot_dir
616                    .open_dir(EFI_LINUX)
617                    .with_context(|| format!("Opening {EFI_LINUX}"))?;
618
619                boot_dir
620            }
621        };
622
623        Ok(boot_dir)
624    }
625
626    /// Access the underlying ostree repository
627    pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> {
628        self.ostree
629            .get()
630            .ok_or_else(|| anyhow::anyhow!("OSTree storage not initialized"))
631    }
632
633    /// Get a cloned reference to the ostree sysroot.
634    ///
635    /// This is used when code needs an owned `ostree::Sysroot` rather than
636    /// a reference to the `SysrootLock`.
637    pub(crate) fn get_ostree_cloned(&self) -> Result<ostree::Sysroot> {
638        let r = self.get_ostree()?;
639        Ok((*r).clone())
640    }
641
642    /// Access the image storage; will automatically initialize it if necessary.
643    ///
644    /// Works on both ostree and composefs-only systems.  On ostree the
645    /// SELinux policy is loaded from the booted deployment; on composefs
646    /// (where ostree isn't initialized) we fall back to the host root policy.
647    pub(crate) fn get_ensure_imgstore(&self) -> Result<&CStorage> {
648        if let Some(imgstore) = self.imgstore.get() {
649            return Ok(imgstore);
650        }
651
652        let (sysroot_dir, sepolicy) = if let Ok(ostree) = self.get_ostree() {
653            let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
654            let sepolicy = if ostree.booted_deployment().is_none() {
655                tracing::trace!("falling back to container root's selinux policy");
656                let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
657                lsm::new_sepolicy_at(&container_root)?
658            } else {
659                tracing::trace!("loading sepolicy from booted ostree deployment");
660                let dep = ostree.booted_deployment().unwrap();
661                let dep_fs = deployment_fd(ostree, &dep)?;
662                lsm::new_sepolicy_at(&dep_fs)?
663            };
664            (sysroot_dir, sepolicy)
665        } else {
666            // Composefs-only: ostree is not initialized. Use the physical
667            // root directly and load SELinux policy from the host root.
668            let sysroot_dir = self.physical_root.try_clone()?;
669            let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
670            let sepolicy = lsm::new_sepolicy_at(&root)?;
671            (sysroot_dir, sepolicy)
672        };
673
674        tracing::trace!("sepolicy in get_ensure_imgstore: {sepolicy:?}");
675
676        let imgstore = CStorage::create(&sysroot_dir, &self.run, sepolicy.as_ref())?;
677        Ok(self.imgstore.get_or_init(|| imgstore))
678    }
679
680    /// Ensure the image storage is properly SELinux-labeled. This should be
681    /// called after all image pulls are complete.
682    pub(crate) fn ensure_imgstore_labeled(&self) -> Result<()> {
683        if let Some(imgstore) = self.imgstore.get() {
684            imgstore.ensure_labeled()?;
685        }
686        Ok(())
687    }
688
689    /// Access the composefs repository; will automatically initialize it if necessary.
690    ///
691    /// This lazily opens the composefs repository, creating the directory if needed
692    /// and bootstrapping verity settings from the ostree configuration.
693    ///
694    /// If the repository already exists on disk, it is opened as-is, preserving
695    /// whatever EROFS format version it was created with (e.g. V2 from an older
696    /// composefs-rs).  A fresh repository is only initialized when no `meta.json`
697    /// is found, using the current default format version from composefs-rs.
698    pub(crate) fn get_ensure_composefs(&self) -> Result<Arc<ComposefsRepository>> {
699        if let Some(composefs) = self.composefs.get() {
700            return Ok(Arc::clone(composefs));
701        }
702
703        ensure_composefs_dir(&self.physical_root)?;
704
705        // Bootstrap verity off of the ostree state. In practice this means disabled by
706        // default right now.
707        let ostree = self.get_ostree()?;
708        let ostree_repo = &ostree.repo();
709        let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?;
710
711        // First, try to open an existing repository.  This respects whatever
712        // EROFS format version (V1 or V2) was persisted in meta.json, avoiding
713        // the "already initialized with different configuration" error that
714        // occurs when the composefs-rs default format version changes between
715        // bootc builds (e.g. V2 → V1 after composefs-rs PR #330).
716        let composefs_dir = self.physical_root.open_dir(COMPOSEFS)?;
717        let composefs = match ComposefsRepository::open_path(&composefs_dir, ".") {
718            Ok(mut repo) => {
719                if !ostree_verity.enabled {
720                    repo.set_insecure();
721                }
722                repo
723            }
724            Err(RepositoryOpenError::MetadataMissing) => {
725                // No meta.json — this is a fresh directory.  Initialize a new
726                // repository with the current defaults.
727                let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
728                let config = if ostree_verity.enabled {
729                    config
730                } else {
731                    config.set_insecure()
732                };
733                let (repo, _created) = ComposefsRepository::init_path(composefs_dir, ".", config)?;
734                repo
735            }
736            Err(RepositoryOpenError::OldFormatRepository) => {
737                // Pre-meta.json repository — use the upgrade path that infers
738                // the algorithm and writes meta.json.
739                let (mut repo, _upgraded) = ComposefsRepository::open_upgrade(&composefs_dir, ".")?;
740                if !ostree_verity.enabled {
741                    repo.set_insecure();
742                }
743                repo
744            }
745            Err(e) => {
746                return Err(anyhow::Error::new(e).context("Opening composefs repository"));
747            }
748        };
749        let composefs = Arc::new(composefs);
750        let r = Arc::clone(self.composefs.get_or_init(|| composefs));
751        Ok(r)
752    }
753
754    /// Update the mtime on the storage root directory.
755    ///
756    /// This touches `ostree/bootc` (or its symlink target on composefs
757    /// systems) so that `bootc-status-updated.path` fires.
758    #[context("Updating storage root mtime")]
759    pub(crate) fn update_mtime(&self) -> Result<()> {
760        // On composefs-only systems ostree is not initialized, so fall
761        // back to the physical root directly.
762        let sysroot_dir = if let Ok(ostree) = self.get_ostree() {
763            crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")?
764        } else {
765            self.physical_root.try_clone()?
766        };
767
768        sysroot_dir
769            .update_timestamps(std::path::Path::new(BOOTC_ROOT))
770            .context("update_timestamps")
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    /// The raw mode returned by metadata includes file type bits (S_IFDIR,
779    /// etc.) in addition to permission bits. This constant masks to only
780    /// the permission bits (owner/group/other rwx).
781    const PERMS: Mode = Mode::from_raw_mode(0o777);
782
783    #[test]
784    fn test_ensure_composefs_dir_mode() -> Result<()> {
785        use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
786
787        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
788
789        let assert_mode = || -> Result<()> {
790            let perms = td.metadata(COMPOSEFS)?.permissions();
791            let mode = Mode::from_raw_mode(perms.mode());
792            assert_eq!(mode & PERMS, COMPOSEFS_MODE);
793            Ok(())
794        };
795
796        ensure_composefs_dir(&td)?;
797        assert_mode()?;
798
799        // Calling again should be a no-op (ensure is idempotent)
800        ensure_composefs_dir(&td)?;
801        assert_mode()?;
802
803        Ok(())
804    }
805
806    #[test]
807    fn test_ensure_composefs_dir_fixes_existing() -> Result<()> {
808        use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
809
810        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
811
812        // Create with overly permissive mode (simulating old bootc behavior)
813        let mut db = DirBuilder::new();
814        db.mode(0o755);
815        td.create_dir_with(COMPOSEFS, &db)?;
816
817        // Verify it starts with wrong permissions
818        let perms = td.metadata(COMPOSEFS)?.permissions();
819        let mode = Mode::from_raw_mode(perms.mode());
820        assert_eq!(mode & PERMS, Mode::from_raw_mode(0o755));
821
822        // ensure_composefs_dir should fix the permissions
823        ensure_composefs_dir(&td)?;
824
825        let perms = td.metadata(COMPOSEFS)?.permissions();
826        let mode = Mode::from_raw_mode(perms.mode());
827        assert_eq!(mode & PERMS, COMPOSEFS_MODE);
828
829        Ok(())
830    }
831}