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::fs::Mode;
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};
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
271impl Environment {
272 /// Detect the current runtime environment.
273 pub(crate) fn detect() -> Result<Self> {
274 if ostree_ext::container_utils::running_in_container() {
275 return Ok(Self::Container);
276 }
277
278 if let Some(cmdline) = composefs_booted()? {
279 return Ok(Self::ComposefsBooted(cmdline.clone()));
280 }
281
282 if ostree_booted()? {
283 return Ok(Self::OstreeBooted);
284 }
285
286 Ok(Self::Other)
287 }
288
289 /// Returns true if this environment requires entering a mount namespace
290 /// before loading storage (to avoid leaving /sysroot writable).
291 pub(crate) fn needs_mount_namespace(&self) -> bool {
292 matches!(self, Self::OstreeBooted | Self::ComposefsBooted(_))
293 }
294}
295
296/// A system can boot via either ostree or composefs; this enum
297/// allows code to handle both cases while maintaining type safety.
298pub(crate) enum BootedStorageKind<'a> {
299 Ostree(BootedOstree<'a>),
300 Composefs(BootedComposefs),
301}
302
303/// Open the physical root (/sysroot) and /run directories for a booted system.
304fn get_physical_root_and_run() -> Result<(Dir, Dir)> {
305 let physical_root = {
306 let d = Dir::open_ambient_dir("/sysroot", cap_std::ambient_authority())
307 .context("Opening /sysroot")?;
308 open_dir_remount_rw(&d, ".".into())?
309 };
310 let run =
311 Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?;
312 Ok((physical_root, run))
313}
314
315impl BootedStorage {
316 /// Create a new booted storage accessor for the given environment.
317 ///
318 /// The caller must have already called `prepare_for_write()` if
319 /// `env.needs_mount_namespace()` is true.
320 pub(crate) async fn new(env: Environment) -> Result<Option<Self>> {
321 let r = match &env {
322 Environment::ComposefsBooted(cmdline) => {
323 let (physical_root, run) = get_physical_root_and_run()?;
324 let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS)?;
325 if cmdline.allow_missing_fsverity {
326 composefs.set_insecure();
327 }
328 let composefs = Arc::new(composefs);
329
330 // Locate ESP by walking up to the root disk(s)
331 let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?;
332 let esp_dev = root_dev.find_first_colocated_esp()?;
333 let esp_mount = mount_esp(&esp_dev.path())?;
334
335 let boot_dir = match get_bootloader()?.kind()? {
336 BootloaderKind::GRUBClassic => {
337 physical_root.open_dir("boot").context("Opening boot")?
338 }
339 // NOTE: Handle XBOOTLDR partitions here if and when we use it
340 BootloaderKind::BLSCompatible => {
341 esp_mount.fd.try_clone().context("Cloning fd")?
342 }
343 };
344
345 let storage = Storage {
346 physical_root,
347 physical_root_path: Utf8PathBuf::from("/sysroot"),
348 run,
349 boot_dir: Some(boot_dir),
350 esp: Some(esp_mount),
351 ostree: Default::default(),
352 composefs: OnceCell::from(composefs.clone()),
353 imgstore: Default::default(),
354 };
355
356 // prepend_custom_prefix is idempotent: it checks has_prefix on each
357 // entry and skips any that already have it, so it's safe to call on
358 // every boot. This handles upgrades from older bootc versions that
359 // lacked the prefix — we can't use meta.json presence as a trigger
360 // because open_upgrade() in the initramfs writes meta.json before
361 // userspace ever runs.
362 let cmdline = composefs_booted()?
363 .ok_or_else(|| anyhow::anyhow!("Could not get booted composefs cmdline"))?;
364 prepend_custom_prefix(&storage, &cmdline).await?;
365
366 Some(Self { storage })
367 }
368 Environment::OstreeBooted => {
369 // The caller must have entered a private mount namespace before
370 // calling this function. This is because ostree's sysroot.load() will
371 // remount /sysroot as writable, and we call set_mount_namespace_in_use()
372 // to indicate we're in a mount namespace. Without actually being in a
373 // mount namespace, this would leave the global /sysroot writable.
374 let (physical_root, run) = get_physical_root_and_run()?;
375
376 let sysroot = ostree::Sysroot::new_default();
377 sysroot.set_mount_namespace_in_use();
378 let sysroot = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?;
379 sysroot.load(gio::Cancellable::NONE)?;
380
381 let storage = Storage {
382 physical_root,
383 physical_root_path: Utf8PathBuf::from("/sysroot"),
384 run,
385 boot_dir: None,
386 esp: None,
387 ostree: OnceCell::from(sysroot),
388 composefs: Default::default(),
389 imgstore: Default::default(),
390 };
391
392 Some(Self { storage })
393 }
394 // For container or non-bootc environments, there's no storage
395 Environment::Container | Environment::Other => None,
396 };
397 Ok(r)
398 }
399
400 /// Determine the boot storage backend kind.
401 ///
402 /// Returns information about whether the system booted via ostree or composefs,
403 /// along with the relevant sysroot/deployment or repository/cmdline data.
404 pub(crate) fn kind(&self) -> Result<BootedStorageKind<'_>> {
405 if let Some(cmdline) = composefs_booted()? {
406 // SAFETY: This must have been set above in new()
407 let repo = self.composefs.get().unwrap();
408 Ok(BootedStorageKind::Composefs(BootedComposefs {
409 repo: Arc::clone(repo),
410 cmdline,
411 }))
412 } else {
413 // SAFETY: This must have been set above in new()
414 let sysroot = self.ostree.get().unwrap();
415 let deployment = sysroot.require_booted_deployment()?;
416 Ok(BootedStorageKind::Ostree(BootedOstree {
417 sysroot,
418 deployment,
419 }))
420 }
421 }
422}
423
424/// A reference to a physical filesystem root, plus
425/// accessors for the different types of container storage.
426pub(crate) struct Storage {
427 /// Directory holding the physical root
428 pub physical_root: Dir,
429
430 /// Absolute path to the physical root directory.
431 /// This is `/sysroot` on a running system, or the target mount point during install.
432 pub physical_root_path: Utf8PathBuf,
433
434 /// The 'boot' directory, useful and `Some` only for composefs systems
435 /// For grub booted systems, this points to `/sysroot/boot`
436 /// For systemd booted systems, this points to the ESP
437 pub boot_dir: Option<Dir>,
438
439 /// The ESP mounted at a tmp location
440 pub esp: Option<TempMount>,
441
442 /// Our runtime state
443 run: Dir,
444
445 /// The OSTree storage
446 ostree: OnceCell<SysrootLock>,
447 /// The composefs storage
448 composefs: OnceCell<Arc<ComposefsRepository>>,
449 /// The containers-image storage used for LBIs
450 imgstore: OnceCell<CStorage>,
451}
452
453/// Cached image status data used for optimization.
454///
455/// This stores the current image status and any cached update information
456/// to avoid redundant fetches during status operations.
457#[derive(Default)]
458pub(crate) struct CachedImageStatus {
459 pub image: Option<ImageStatus>,
460 pub cached_update: Option<ImageStatus>,
461}
462
463impl Storage {
464 /// Create a new storage accessor from an existing ostree sysroot.
465 ///
466 /// This is used for non-booted scenarios (e.g., `bootc install`) where
467 /// we're operating on a target filesystem rather than the running system.
468 pub fn new_ostree(sysroot: SysrootLock, run: &Dir) -> Result<Self> {
469 let run = run.try_clone()?;
470
471 // ostree has historically always relied on
472 // having ostree -> sysroot/ostree as a symlink in the image to
473 // make it so that code doesn't need to distinguish between booted
474 // vs offline target. The ostree code all just looks at the ostree/
475 // directory, and will follow the link in the booted case.
476 //
477 // For composefs we aren't going to do a similar thing, so here
478 // we need to explicitly distinguish the two and the storage
479 // here hence holds a reference to the physical root.
480 let ostree_sysroot_dir = crate::utils::sysroot_dir(&sysroot)?;
481 let (physical_root, physical_root_path) = if sysroot.is_booted() {
482 (
483 ostree_sysroot_dir.open_dir(SYSROOT)?,
484 Utf8PathBuf::from("/sysroot"),
485 )
486 } else {
487 // For non-booted case (install), get the path from the sysroot
488 let path = sysroot.path();
489 let path_str = path.parse_name().to_string();
490 let path = Utf8PathBuf::from(path_str);
491 (ostree_sysroot_dir, path)
492 };
493
494 let ostree_cell = OnceCell::new();
495 let _ = ostree_cell.set(sysroot);
496
497 Ok(Self {
498 physical_root,
499 physical_root_path,
500 run,
501 boot_dir: None,
502 esp: None,
503 ostree: ostree_cell,
504 composefs: Default::default(),
505 imgstore: Default::default(),
506 })
507 }
508
509 /// Returns `boot_dir` if it exists
510 pub(crate) fn require_boot_dir(&self) -> Result<&Dir> {
511 self.boot_dir
512 .as_ref()
513 .ok_or_else(|| anyhow::anyhow!("Boot dir not found"))
514 }
515
516 /// Returns the mounted `esp` if it exists
517 pub(crate) fn require_esp(&self) -> Result<&TempMount> {
518 self.esp
519 .as_ref()
520 .ok_or_else(|| anyhow::anyhow!("ESP not found"))
521 }
522
523 /// Returns the Directory where the Type1 boot binaries are stored
524 /// `/sysroot/boot` for Grub, and ESP/EFI/Linux for systemd-boot
525 pub(crate) fn bls_boot_binaries_dir(&self) -> Result<Dir> {
526 let boot_dir = self.require_boot_dir()?;
527
528 // boot dir in case of systemd-boot points to the ESP, but we store
529 // the actual binaries inside ESP/EFI/Linux
530 let boot_dir = match get_bootloader()?.kind()? {
531 BootloaderKind::GRUBClassic => boot_dir.try_clone()?,
532 BootloaderKind::BLSCompatible => {
533 let boot_dir = boot_dir
534 .open_dir(EFI_LINUX)
535 .with_context(|| format!("Opening {EFI_LINUX}"))?;
536
537 boot_dir
538 }
539 };
540
541 Ok(boot_dir)
542 }
543
544 /// Access the underlying ostree repository
545 pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> {
546 self.ostree
547 .get()
548 .ok_or_else(|| anyhow::anyhow!("OSTree storage not initialized"))
549 }
550
551 /// Get a cloned reference to the ostree sysroot.
552 ///
553 /// This is used when code needs an owned `ostree::Sysroot` rather than
554 /// a reference to the `SysrootLock`.
555 pub(crate) fn get_ostree_cloned(&self) -> Result<ostree::Sysroot> {
556 let r = self.get_ostree()?;
557 Ok((*r).clone())
558 }
559
560 /// Access the image storage; will automatically initialize it if necessary.
561 ///
562 /// Works on both ostree and composefs-only systems. On ostree the
563 /// SELinux policy is loaded from the booted deployment; on composefs
564 /// (where ostree isn't initialized) we fall back to the host root policy.
565 pub(crate) fn get_ensure_imgstore(&self) -> Result<&CStorage> {
566 if let Some(imgstore) = self.imgstore.get() {
567 return Ok(imgstore);
568 }
569
570 let (sysroot_dir, sepolicy) = if let Ok(ostree) = self.get_ostree() {
571 let sysroot_dir = crate::utils::sysroot_dir(ostree)?;
572 let sepolicy = if ostree.booted_deployment().is_none() {
573 tracing::trace!("falling back to container root's selinux policy");
574 let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
575 lsm::new_sepolicy_at(&container_root)?
576 } else {
577 tracing::trace!("loading sepolicy from booted ostree deployment");
578 let dep = ostree.booted_deployment().unwrap();
579 let dep_fs = deployment_fd(ostree, &dep)?;
580 lsm::new_sepolicy_at(&dep_fs)?
581 };
582 (sysroot_dir, sepolicy)
583 } else {
584 // Composefs-only: ostree is not initialized. Use the physical
585 // root directly and load SELinux policy from the host root.
586 let sysroot_dir = self.physical_root.try_clone()?;
587 let root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
588 let sepolicy = lsm::new_sepolicy_at(&root)?;
589 (sysroot_dir, sepolicy)
590 };
591
592 tracing::trace!("sepolicy in get_ensure_imgstore: {sepolicy:?}");
593
594 let imgstore = CStorage::create(&sysroot_dir, &self.run, sepolicy.as_ref())?;
595 Ok(self.imgstore.get_or_init(|| imgstore))
596 }
597
598 /// Ensure the image storage is properly SELinux-labeled. This should be
599 /// called after all image pulls are complete.
600 pub(crate) fn ensure_imgstore_labeled(&self) -> Result<()> {
601 if let Some(imgstore) = self.imgstore.get() {
602 imgstore.ensure_labeled()?;
603 }
604 Ok(())
605 }
606
607 /// Access the composefs repository; will automatically initialize it if necessary.
608 ///
609 /// This lazily opens the composefs repository, creating the directory if needed
610 /// and bootstrapping verity settings from the ostree configuration.
611 ///
612 /// If the repository already exists on disk, it is opened as-is, preserving
613 /// whatever EROFS format version it was created with (e.g. V2 from an older
614 /// composefs-rs). A fresh repository is only initialized when no `meta.json`
615 /// is found, using the current default format version from composefs-rs.
616 pub(crate) fn get_ensure_composefs(&self) -> Result<Arc<ComposefsRepository>> {
617 if let Some(composefs) = self.composefs.get() {
618 return Ok(Arc::clone(composefs));
619 }
620
621 ensure_composefs_dir(&self.physical_root)?;
622
623 // Bootstrap verity off of the ostree state. In practice this means disabled by
624 // default right now.
625 let ostree = self.get_ostree()?;
626 let ostree_repo = &ostree.repo();
627 let ostree_verity = ostree_ext::fsverity::is_verity_enabled(ostree_repo)?;
628
629 // First, try to open an existing repository. This respects whatever
630 // EROFS format version (V1 or V2) was persisted in meta.json, avoiding
631 // the "already initialized with different configuration" error that
632 // occurs when the composefs-rs default format version changes between
633 // bootc builds (e.g. V2 → V1 after composefs-rs PR #330).
634 let composefs_dir = self.physical_root.open_dir(COMPOSEFS)?;
635 let composefs = match ComposefsRepository::open_path(&composefs_dir, ".") {
636 Ok(mut repo) => {
637 if !ostree_verity.enabled {
638 repo.set_insecure();
639 }
640 repo
641 }
642 Err(RepositoryOpenError::MetadataMissing) => {
643 // No meta.json — this is a fresh directory. Initialize a new
644 // repository with the current defaults.
645 let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
646 let config = if ostree_verity.enabled {
647 config
648 } else {
649 config.set_insecure()
650 };
651 let (repo, _created) = ComposefsRepository::init_path(composefs_dir, ".", config)?;
652 repo
653 }
654 Err(RepositoryOpenError::OldFormatRepository) => {
655 // Pre-meta.json repository — use the upgrade path that infers
656 // the algorithm and writes meta.json.
657 let (mut repo, _upgraded) = ComposefsRepository::open_upgrade(&composefs_dir, ".")?;
658 if !ostree_verity.enabled {
659 repo.set_insecure();
660 }
661 repo
662 }
663 Err(e) => {
664 return Err(anyhow::Error::new(e).context("Opening composefs repository"));
665 }
666 };
667 let composefs = Arc::new(composefs);
668 let r = Arc::clone(self.composefs.get_or_init(|| composefs));
669 Ok(r)
670 }
671
672 /// Update the mtime on the storage root directory.
673 ///
674 /// This touches `ostree/bootc` (or its symlink target on composefs
675 /// systems) so that `bootc-status-updated.path` fires.
676 #[context("Updating storage root mtime")]
677 pub(crate) fn update_mtime(&self) -> Result<()> {
678 // On composefs-only systems ostree is not initialized, so fall
679 // back to the physical root directly.
680 let sysroot_dir = if let Ok(ostree) = self.get_ostree() {
681 crate::utils::sysroot_dir(ostree).context("Reopen sysroot directory")?
682 } else {
683 self.physical_root.try_clone()?
684 };
685
686 sysroot_dir
687 .update_timestamps(std::path::Path::new(BOOTC_ROOT))
688 .context("update_timestamps")
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 /// The raw mode returned by metadata includes file type bits (S_IFDIR,
697 /// etc.) in addition to permission bits. This constant masks to only
698 /// the permission bits (owner/group/other rwx).
699 const PERMS: Mode = Mode::from_raw_mode(0o777);
700
701 #[test]
702 fn test_ensure_composefs_dir_mode() -> Result<()> {
703 use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
704
705 let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
706
707 let assert_mode = || -> Result<()> {
708 let perms = td.metadata(COMPOSEFS)?.permissions();
709 let mode = Mode::from_raw_mode(perms.mode());
710 assert_eq!(mode & PERMS, COMPOSEFS_MODE);
711 Ok(())
712 };
713
714 ensure_composefs_dir(&td)?;
715 assert_mode()?;
716
717 // Calling again should be a no-op (ensure is idempotent)
718 ensure_composefs_dir(&td)?;
719 assert_mode()?;
720
721 Ok(())
722 }
723
724 #[test]
725 fn test_ensure_composefs_dir_fixes_existing() -> Result<()> {
726 use cap_std_ext::cap_primitives::fs::PermissionsExt as _;
727
728 let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
729
730 // Create with overly permissive mode (simulating old bootc behavior)
731 let mut db = DirBuilder::new();
732 db.mode(0o755);
733 td.create_dir_with(COMPOSEFS, &db)?;
734
735 // Verify it starts with wrong permissions
736 let perms = td.metadata(COMPOSEFS)?.permissions();
737 let mode = Mode::from_raw_mode(perms.mode());
738 assert_eq!(mode & PERMS, Mode::from_raw_mode(0o755));
739
740 // ensure_composefs_dir should fix the permissions
741 ensure_composefs_dir(&td)?;
742
743 let perms = td.metadata(COMPOSEFS)?.permissions();
744 let mode = Mode::from_raw_mode(perms.mode());
745 assert_eq!(mode & PERMS, COMPOSEFS_MODE);
746
747 Ok(())
748 }
749}