From d77867dcf611851bb760a5b5ae4a669d89bb7af8 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 29 Oct 2026 17:00:47 +0000 Subject: [PATCH] image: Mount deployment image content Expose paths from booted or staged deployment images through caller-owned read-only mounts. Keep image content and persistent /var ownership separate, and add explicit unmount and integration coverage. Assisted-by: AI Signed-off-by: ckyrouac --- crates/initramfs/src/lib.rs | 22 + crates/lib/src/cli.rs | 6 + crates/lib/src/image_mount.rs | 667 ++++++++++++++++++++++++ crates/lib/src/lib.rs | 1 + docs/src/bootc-experimental-image.7.md | 42 +- tmt/plans/integration.fmf | 7 + tmt/tests/booted/test-64-image-mount.nu | 116 +++++ tmt/tests/tests.fmf | 5 + 8 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 crates/lib/src/image_mount.rs create mode 100644 tmt/tests/booted/test-64-image-mount.nu diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index ed989c57da..a0de905e5a 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -38,6 +38,9 @@ use linux_kernel_cmdline::utf8::Cmdline; // mount_setattr syscall support const MOUNT_ATTR_RDONLY: u64 = 0x00000001; +const MOUNT_ATTR_NOSUID: u64 = 0x00000002; +const MOUNT_ATTR_NODEV: u64 = 0x00000004; +const MOUNT_ATTR_NOSYMFOLLOW: u64 = 0x00200000; #[repr(C)] struct MountAttr { @@ -91,6 +94,25 @@ pub fn set_mount_tree_readonly(fd: impl AsFd) -> Result<()> { mount_setattr(fd, libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, &attr) } +/// Set a mount tree read-only, nosuid, nodev, and prevent symlink traversal within it. +/// +/// A bind-mounted image tree is not a chroot: absolute and escaping symlinks +/// can otherwise resolve against the caller's root. `nosymfollow` keeps the +/// exposed tree confined to the image while leaving the underlying image +/// untouched. `nosuid` also prevents set-user-ID and set-group-ID bits in the +/// image from affecting processes that access the mounted tree. This deliberately +/// does not set `noexec`, so image executables remain runnable. +#[context("Setting mount tree readonly and nosymfollow")] +pub fn set_mount_tree_readonly_nosymfollow(fd: impl AsFd) -> Result<()> { + let attr = MountAttr { + attr_set: MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV | MOUNT_ATTR_NOSYMFOLLOW, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + mount_setattr(fd, libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, &attr) +} + /// Types of mounts supported by the configuration #[derive(Clone, Copy, Debug, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ed784c4e59..d75d61f2dd 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -765,6 +765,10 @@ pub(crate) enum ImageOpts { /// The image to pull image: String, }, + /// Mount content from the booted or staged deployment image read-only. + Mount(crate::image_mount::MountOpts), + /// Unmount the named non-root mount (non-recursive; bootc provenance is not checked). + Unmount(crate::image_mount::UnmountOpts), /// Wrapper for selected `podman image` subcommands in bootc storage. #[clap(subcommand)] Cmd(ImageCmdOpts), @@ -2496,6 +2500,8 @@ async fn run_from_opt(opt: Opt) -> Result { .pull_from_host_storage(&image) .await } + ImageOpts::Mount(opts) => crate::image_mount::mount(opts).await, + ImageOpts::Unmount(opts) => crate::image_mount::unmount(opts), ImageOpts::Cmd(opt) => { let storage = get_storage().await?; let imgstore = storage.get_ensure_imgstore()?; diff --git a/crates/lib/src/image_mount.rs b/crates/lib/src/image_mount.rs new file mode 100644 index 0000000000..86a8723055 --- /dev/null +++ b/crates/lib/src/image_mount.rs @@ -0,0 +1,667 @@ +//! Read-only access to the image content of a booted or staged deployment. +//! +//! This deliberately does not assemble deployment state: in particular, it +//! never bind-mounts `/var` or the deployment's mutable `/etc` over the image. + +use std::os::fd::{AsFd, AsRawFd, OwnedFd}; +use std::os::unix::fs::MetadataExt; + +use anyhow::{Context, Result, bail, ensure}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use clap::{Args, ValueEnum}; +use composefs_ctl::composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use ostree::gio; +use ostree_ext::{ostree, ostree_prepareroot, sysroot::SysrootLock}; +use rustix::fs::{AtFlags, CWD, Mode, OFlags, StatxFlags, openat, statx}; +use rustix::mount::{ + MoveMountFlags, OpenTreeFlags, UnmountFlags, move_mount, open_tree, unmount as unmount_path, +}; + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +#[clap(rename_all = "lowercase")] +pub(crate) enum Deployment { + Booted, + Staged, +} + +#[derive(Debug, Args, PartialEq, Eq)] +pub(crate) struct MountOpts { + /// Select exactly the booted deployment or the pending staged deployment. + #[arg(long, value_enum)] + pub(crate) deployment: Deployment, + + /// Absolute directory path inside the selected image to expose (`/` selects its root). + /// No path component may be a symlink. + #[arg(long, value_parser = crate::cli::parse_absolute_path)] + pub(crate) source: Utf8PathBuf, + + /// Existing empty absolute directory to mount the selected image content on. + #[arg(long, value_parser = crate::cli::parse_absolute_path)] + pub(crate) target: Utf8PathBuf, +} + +#[derive(Debug, Args, PartialEq, Eq)] +pub(crate) struct UnmountOpts { + /// Named mountpoint to unmount (non-recursive; bootc provenance is not checked). + #[arg(value_parser = crate::cli::parse_absolute_path)] + pub(crate) target: Utf8PathBuf, +} + +struct MountedImageRoot { + tree: OwnedFd, + // Keep the repository/sysroot locked while cloning and attaching its tree. + _storage: StorageGuard, + // A non-composefs OSTree checkout has deployment-local /etc. Replace it + // with pristine image content from /usr/etc before exposing the tree. + checkout: bool, +} + +enum StorageGuard { + Composefs { + _repo: crate::store::ComposefsRepository, + }, + Ostree { + _sysroot: SysrootLock, + }, +} + +pub(crate) async fn mount(opts: MountOpts) -> Result<()> { + source_relative_path(&opts.source)?; + let composefs_booted = crate::bootc_composefs::status::composefs_booted()?; + // Select only from the active backend. These lookups are deliberately + // narrower than `status::get_host()`: they do not initialize storage, + // migrate repository metadata, configure the ESP, or change namespaces. + let image = if let Some(booted) = composefs_booted { + let (id, allow_missing_fsverity) = match opts.deployment { + Deployment::Booted => (booted.digest.to_string(), booted.allow_missing_fsverity), + Deployment::Staged => ( + staged_composefs_deployment_id(&booted.digest)?, + booted.allow_missing_fsverity, + ), + }; + mount_composefs_image(&opts, id, allow_missing_fsverity)? + } else { + mount_ostree_image(&opts).await? + }; + let MountedImageRoot { + tree, + _storage, + checkout, + } = image; + + if checkout { + mount_pristine_etc(&tree)?; + } + bootc_initramfs_setup::set_mount_tree_readonly(&tree) + .context("Making selected deployment image read-only")?; + + let image_root = Dir::reopen_dir(&tree).context("Opening selected image root")?; + let source = open_source(&image_root, &opts.source)?; + // Open once and retain this descriptor through validation and attachment. + // This avoids checking one pathname and mounting on a replacement object. + let (target, target_parent) = open_mount_target(&opts.target)?; + ensure_not_root_target(&target, &opts.target)?; + let source_metadata = source.metadata().context("Inspecting image source")?; + let target_metadata = target + .metadata() + .with_context(|| format!("Inspecting mount target {}", opts.target))?; + ensure!( + source_metadata.is_dir() && target_metadata.is_dir(), + "image source {} and target {} must both be directories", + opts.source, + opts.target + ); + + let source_path = format!("/proc/self/fd/{}", source.as_raw_fd()); + let source_tree = open_tree( + CWD, + source_path.as_str(), + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, + ) + .with_context(|| format!("Cloning image source {}", opts.source))?; + bootc_initramfs_setup::set_mount_tree_readonly_nosymfollow(&source_tree) + .context("Making selected image content read-only and preventing symlink traversal")?; + + // Check immediately before attachment so a changed target cannot silently + // hide newly-added contents or stack another mount. + ensure_directory_empty(&target, &opts.target)?; + if let Some(parent) = target_parent.as_ref() { + ensure_not_mountpoint(&target, parent, &opts.target)?; + } + + move_mount( + &source_tree, + "", + &target, + "", + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH, + ) + .with_context(|| format!("Attaching image source {} at {}", opts.source, opts.target))?; + + Ok(()) +} + +/// Read the composefs staged selector without constructing `BootedStorage`. +/// The staged ID is valid only when it names a distinct deployment with local +/// composefs state and a parseable origin file. +fn staged_composefs_deployment_id(booted_id: &str) -> Result { + use crate::composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, + }; + + let staged_path = + format!("{COMPOSEFS_TRANSIENT_STATE_DIR}/{COMPOSEFS_STAGED_DEPLOYMENT_FNAME}"); + let staged_json = std::fs::read_to_string(&staged_path) + .with_context(|| format!("Reading staged composefs deployment selector {staged_path}"))?; + let id = staged_composefs_id_from_json(&staged_json, booted_id)?; + + let sysroot = Dir::open_ambient_dir("/sysroot", ambient_authority()) + .context("Opening booted system sysroot")?; + let origin = crate::bootc_composefs::state::read_origin(&sysroot, &id)?.ok_or_else(|| { + anyhow::anyhow!("No composefs state or origin for staged deployment {id}") + })?; + let boot_type = origin + .get::( + crate::composefs_consts::ORIGIN_KEY_BOOT, + crate::composefs_consts::ORIGIN_KEY_BOOT_TYPE, + ) + .ok_or_else(|| { + anyhow::anyhow!("Staged composefs deployment {id} has no boot type in its origin") + })?; + crate::bootc_composefs::boot::BootType::try_from(boot_type.as_str()) + .context("Parsing staged composefs deployment boot type")?; + Ok(id) +} + +fn staged_composefs_id_from_json(contents: &str, booted_id: &str) -> Result { + let staged: crate::bootc_composefs::status::StagedDeployment = + serde_json::from_str(contents).context("Parsing staged composefs deployment selector")?; + let id = Sha512HashValue::from_hex(&staged.depl_id) + .context("Parsing staged composefs deployment ID")? + .to_hex(); + ensure!(id != booted_id, "No staged composefs deployment found"); + Ok(id) +} + +fn mount_composefs_image( + opts: &MountOpts, + id: String, + allow_missing_fsverity: bool, +) -> Result { + let sysroot = Dir::open_ambient_dir("/sysroot", ambient_authority()) + .context("Opening booted system sysroot")?; + let mut repo = crate::bootc_composefs::repo::open_composefs_repo(&sysroot) + .context("Opening existing composefs repository")?; + if allow_missing_fsverity { + repo.set_insecure(); + } + let tree = repo + .mount(&id) + .with_context(|| format!("Mounting {:?} composefs deployment {id}", opts.deployment))?; + Ok(MountedImageRoot { + tree, + _storage: StorageGuard::Composefs { _repo: repo }, + checkout: false, + }) +} + +async fn mount_ostree_image(opts: &MountOpts) -> Result { + // Open /sysroot through a retained directory fd and do the same narrow + // load/lock sequence used by install-mount. In particular, do not call + // prepare_for_write() or initialize boot storage for this read-only query. + let sysroot_dir = Dir::open_ambient_dir("/sysroot", ambient_authority()) + .context("Opening booted system sysroot")?; + let path = format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd()); + let sysroot_file = gio::File::for_path(path); + let sysroot = ostree::Sysroot::new(Some(&sysroot_file)); + sysroot + .load(gio::Cancellable::NONE) + .context("Loading booted OSTree sysroot")?; + let sysroot = SysrootLock::new_from_sysroot(&sysroot) + .await + .context("Locking booted OSTree sysroot")?; + + let deployment = match opts.deployment { + Deployment::Booted => sysroot + .require_booted_deployment() + .context("No booted OSTree deployment found")? + .clone(), + Deployment::Staged => { + let booted = sysroot + .require_booted_deployment() + .context("No booted OSTree deployment found")?; + sysroot + .deployments() + .into_iter() + .find(|deployment| deployment.is_staged() && deployment.osname() == booted.osname()) + .ok_or_else(|| anyhow::anyhow!("No staged OSTree deployment found"))? + } + }; + let sysroot_dir = crate::utils::sysroot_dir(&sysroot)?; + let deployment_path = sysroot.deployment_dirpath(&deployment); + let deployment_dir = sysroot_dir + .open_dir(deployment_path.as_str()) + .with_context(|| format!("Opening selected OSTree deployment {deployment_path}"))?; + let repo = sysroot_dir + .open_dir("ostree/repo") + .context("Opening booted OSTree repository")?; + let config = ostree_prepareroot::load_config_from_root(&deployment_dir) + .context("Loading selected deployment's prepare-root.conf")?; + let (tree, checkout) = match ostree_prepareroot::mount_composefs( + &deployment_dir, + &repo, + deployment.csum().as_str(), + config.as_ref(), + ) + .context("Mounting selected OSTree deployment image")? + { + Some(tree) => (tree, false), + None => ( + open_tree( + &sysroot_dir, + deployment_path.as_str(), + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, + ) + .context("Cloning selected OSTree deployment checkout")?, + true, + ), + }; + Ok(MountedImageRoot { + tree, + _storage: StorageGuard::Ostree { _sysroot: sysroot }, + checkout, + }) +} + +fn mount_pristine_etc(tree: &impl AsFd) -> Result<()> { + let root = Dir::reopen_dir(tree).context("Opening selected OSTree checkout")?; + let usr_etc = root + .open_dir("usr/etc") + .context("Opening pristine image /usr/etc")?; + let etc_tree = open_tree( + &usr_etc, + ".", + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, + ) + .context("Cloning pristine image /usr/etc")?; + move_mount( + &etc_tree, + "", + &root, + "etc", + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("Replacing deployment-local /etc with pristine image /usr/etc") +} + +fn open_source(root: &Dir, source: &Utf8Path) -> Result { + let relative = source_relative_path(source)?; + let components = relative + .components() + .filter_map(|component| match component { + camino::Utf8Component::Normal(component) => Some(component), + _ => None, + }) + .collect::>(); + + let mut parent: Option = None; + if components.is_empty() { + let fd = openat( + root, + ".", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .context("Opening image root")?; + return Ok(std::fs::File::from(fd)); + } + + for (index, component) in components.iter().enumerate() { + let fd = match &parent { + Some(parent) => openat( + parent, + *component, + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ), + None => openat( + root, + *component, + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ), + } + .with_context(|| format!("Opening image source component {component} in {source}"))?; + let file = std::fs::File::from(fd); + let metadata = file + .metadata() + .with_context(|| format!("Inspecting image source component {component}"))?; + ensure!( + !metadata.file_type().is_symlink(), + "image source path components must not be symlinks: {source}" + ); + if index + 1 < components.len() { + ensure!( + metadata.is_dir(), + "image source path component {component} is not a directory" + ); + parent = Some(file.into()); + } else { + return Ok(file); + } + } + unreachable!("non-empty component list returns from the loop") +} + +fn source_relative_path(source: &Utf8Path) -> Result { + ensure!( + source.is_absolute(), + "image source must be absolute: {source}" + ); + for component in source.components() { + if matches!(component, camino::Utf8Component::ParentDir) { + bail!("image source must not contain `..`: {source}"); + } + } + source + .strip_prefix("/") + .map(|path| path.to_owned()) + .with_context(|| format!("Invalid absolute image source {source}")) +} + +pub(crate) fn unmount(opts: UnmountOpts) -> Result<()> { + // Pin the object named by the explicit target and ask the kernel to unmount + // through its procfs fd link. The kernel rejects the operation unless this + // descriptor resolves to a mount root; no recursive flag is used. + let target = open_existing_path(&opts.target)?; + ensure_not_root_target(&target, &opts.target)?; + let fd_path = format!("/proc/self/fd/{}", target.as_raw_fd()); + unmount_path(fd_path.as_str(), UnmountFlags::empty()) + .with_context(|| format!("Unmounting {}", opts.target))?; + Ok(()) +} + +fn open_existing_path(path: &Utf8Path) -> Result { + open_path_no_symlinks(path, false).map(|(target, _)| target) +} + +fn open_mount_target(path: &Utf8Path) -> Result<(std::fs::File, Option)> { + open_path_no_symlinks(path, true) +} + +/// Resolve an absolute path one component at a time, retaining the parent +/// descriptor so checks and mount attachment refer to the same directory. +fn open_path_no_symlinks( + path: &Utf8Path, + final_directory: bool, +) -> Result<(std::fs::File, Option)> { + ensure!(path.is_absolute(), "target path must be absolute: {path}"); + ensure!( + path.components().all(|component| !matches!( + component, + camino::Utf8Component::ParentDir | camino::Utf8Component::Prefix(_) + )), + "target path must not contain `..`: {path}" + ); + let components = path + .components() + .filter_map(|component| match component { + camino::Utf8Component::RootDir => None, + camino::Utf8Component::Normal(component) => Some(component.to_owned()), + camino::Utf8Component::CurDir => None, + camino::Utf8Component::ParentDir | camino::Utf8Component::Prefix(_) => unreachable!(), + }) + .collect::>(); + + let root = openat( + CWD, + "/", + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .context("Opening filesystem root")?; + let mut parent = std::fs::File::from(root); + if components.is_empty() { + ensure!(!final_directory, "mount target must not be filesystem root"); + return Ok((parent, None)); + } + + for (index, component) in components.iter().enumerate() { + let final_component = index + 1 == components.len(); + let mut flags = OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC; + if !final_component || final_directory { + flags |= OFlags::DIRECTORY; + } + let fd = openat(&parent, component.as_str(), flags, Mode::empty()) + .with_context(|| format!("Opening target path component {component} in {path}"))?; + let current = std::fs::File::from(fd); + let metadata = current + .metadata() + .with_context(|| format!("Inspecting target path component {component} in {path}"))?; + ensure!( + !metadata.file_type().is_symlink(), + "target path components must not be symlinks: {path}" + ); + if final_component { + return Ok((current, Some(parent))); + } + parent = current; + } + unreachable!("non-empty component list returns from the loop") +} + +fn ensure_directory_empty(target: &impl AsFd, path: &Utf8Path) -> Result<()> { + let mut entries = Dir::reopen_dir(target) + .with_context(|| format!("Opening mount target directory {path}"))? + .entries()?; + ensure!( + entries.next().is_none(), + "mount target {path} must be empty" + ); + Ok(()) +} + +/// Compare Linux mount IDs for a directory and its parent. A different ID +/// means the target is already a mountpoint, including an overmount. +fn ensure_not_mountpoint(target: &impl AsFd, parent: &impl AsFd, path: &Utf8Path) -> Result<()> { + let target_mount_id = mount_id(target)?; + let parent_mount_id = mount_id(parent)?; + ensure!( + target_mount_id == parent_mount_id, + "mount target {path} is already a mountpoint" + ); + Ok(()) +} + +fn mount_id(fd: &impl AsFd) -> Result { + let stat = statx(fd.as_fd(), "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) + .context("Reading mount ID with statx")?; + ensure!( + stat.stx_mask & StatxFlags::MNT_ID.bits() == StatxFlags::MNT_ID.bits(), + "kernel did not provide mount IDs via statx (STATX_MNT_ID is required)" + ); + Ok(stat.stx_mnt_id) +} + +fn ensure_not_root_target(target: &std::fs::File, path: &Utf8Path) -> Result<()> { + let root = openat(CWD, "/", OFlags::PATH | OFlags::CLOEXEC, Mode::empty()) + .context("Opening filesystem root")?; + let root = std::fs::File::from(root); + let target_metadata = target + .metadata() + .with_context(|| format!("Inspecting target {path}"))?; + let root_metadata = root.metadata().context("Inspecting filesystem root")?; + ensure!( + target_metadata.dev() != root_metadata.dev() + || target_metadata.ino() != root_metadata.ino(), + "Refusing to mount or unmount the root filesystem" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + Deployment, MountOpts, UnmountOpts, ensure_directory_empty, ensure_not_mountpoint, + ensure_not_root_target, open_existing_path, open_mount_target, open_source, + source_relative_path, + }; + use crate::cli::{ImageOpts, InstallOpts, Opt}; + use camino::{Utf8Path, Utf8PathBuf}; + use cap_std_ext::cap_std::ambient_authority; + use clap::Parser; + use std::os::unix::fs::symlink; + + #[test] + fn parses_image_mount_and_unmount_without_changing_install_mount() { + let parsed = Opt::try_parse_from([ + "bootc", + "image", + "mount", + "--deployment", + "staged", + "--source", + "/usr", + "--target", + "/mnt/image", + ]) + .unwrap(); + assert!(matches!( + parsed, + Opt::Image(ImageOpts::Mount(MountOpts { + deployment: Deployment::Staged, + .. + })) + )); + assert!(matches!( + Opt::try_parse_from(["bootc", "image", "unmount", "/mnt/image"]).unwrap(), + Opt::Image(ImageOpts::Unmount(UnmountOpts { .. })) + )); + assert!(matches!( + Opt::try_parse_from([ + "bootc", + "install", + "mount", + "--sysroot", + "/sysroot", + "--latest", + "/mnt/installed" + ]) + .unwrap(), + Opt::Install(InstallOpts::Mount(_)) + )); + } + + #[test] + fn source_paths_must_stay_beneath_image_root() { + for path in ["relative", "/usr/../etc", "/../../etc"] { + assert!(source_relative_path(Utf8Path::new(path)).is_err(), "{path}"); + } + assert_eq!( + source_relative_path(Utf8Path::new("/usr/bin")) + .unwrap() + .as_str(), + "usr/bin" + ); + assert_eq!( + source_relative_path(Utf8Path::new("/")).unwrap().as_str(), + "" + ); + } + + #[test] + fn refuses_root_as_mount_or_unmount_target() { + let root = open_existing_path(Utf8Path::new("/")).unwrap(); + assert!(ensure_not_root_target(&root, Utf8Path::new("/")).is_err()); + + let temp = tempfile::tempdir().unwrap(); + let target = Utf8PathBuf::from_path_buf(temp.path().to_owned()).unwrap(); + let target_fd = open_existing_path(&target).unwrap(); + assert!(ensure_not_root_target(&target_fd, &target).is_ok()); + + let root_alias = temp.path().join("root-alias"); + symlink("/", &root_alias).unwrap(); + let root_alias = Utf8PathBuf::from_path_buf(root_alias).unwrap(); + assert!(open_existing_path(&root_alias).is_err()); + } + + #[test] + fn source_lookup_rejects_symlink_components_and_preserves_file_directory_matches() { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("real-dir")).unwrap(); + std::fs::write(temp.path().join("real-file"), b"image").unwrap(); + symlink("/", temp.path().join("absolute-link")).unwrap(); + symlink("real-file", temp.path().join("file-link")).unwrap(); + let root = + cap_std_ext::cap_std::fs::Dir::open_ambient_dir(temp.path(), ambient_authority()) + .unwrap(); + + assert!(open_source(&root, Utf8Path::new("/absolute-link/etc")).is_err()); + assert!(open_source(&root, Utf8Path::new("/file-link")).is_err()); + assert!( + open_source(&root, Utf8Path::new("/real-file")) + .unwrap() + .metadata() + .unwrap() + .is_file() + ); + assert!( + open_source(&root, Utf8Path::new("/real-dir")) + .unwrap() + .metadata() + .unwrap() + .is_dir() + ); + assert!( + open_source(&root, Utf8Path::new("/")) + .unwrap() + .metadata() + .unwrap() + .is_dir() + ); + } + + #[test] + fn mount_target_must_be_empty_and_have_no_symlink_components() { + let temp = tempfile::tempdir().unwrap(); + let target_path = Utf8PathBuf::from_path_buf(temp.path().join("empty")).unwrap(); + std::fs::create_dir(&target_path).unwrap(); + let (target, parent) = open_mount_target(&target_path).unwrap(); + ensure_directory_empty(&target, &target_path).unwrap(); + ensure_not_mountpoint(&target, parent.as_ref().unwrap(), &target_path).unwrap(); + + std::fs::write(target_path.join("existing"), b"content").unwrap(); + assert!(ensure_directory_empty(&target, &target_path).is_err()); + + let linked_target = temp.path().join("linked-target"); + symlink(&target_path, &linked_target).unwrap(); + let linked_target = Utf8PathBuf::from_path_buf(linked_target).unwrap(); + assert!(open_mount_target(&linked_target).is_err()); + + let linked_parent = temp.path().join("linked-parent"); + symlink(temp.path(), &linked_parent).unwrap(); + let nested_target = Utf8PathBuf::from_path_buf(linked_parent.join("empty")).unwrap(); + assert!(open_mount_target(&nested_target).is_err()); + } + + #[test] + fn staged_composefs_selector_requires_a_valid_distinct_digest() { + let booted = "aa".repeat(64); + let staged = "bb".repeat(64); + let valid_json = format!(r#"{{"depl_id":"{staged}","finalization_locked":false}}"#); + assert_eq!( + super::staged_composefs_id_from_json(&valid_json, &booted).unwrap(), + staged + ); + assert!(super::staged_composefs_id_from_json(&valid_json, &staged).is_err()); + assert!(super::staged_composefs_id_from_json("{}", &booted).is_err()); + assert!( + super::staged_composefs_id_from_json( + r#"{"depl_id":"not-a-digest","finalization_locked":false}"#, + &booted + ) + .is_err() + ); + } +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 428ba21a21..595e84d341 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -82,6 +82,7 @@ pub(crate) mod fsck; pub(crate) mod generator; mod glyph; mod image; +mod image_mount; mod install; pub(crate) mod journal; mod k8sapitypes; diff --git a/docs/src/bootc-experimental-image.7.md b/docs/src/bootc-experimental-image.7.md index e86a7c8e39..0b88017db2 100644 --- a/docs/src/bootc-experimental-image.7.md +++ b/docs/src/bootc-experimental-image.7.md @@ -15,6 +15,47 @@ then be managed independently; used as a base image, pushed to a registry, etc. Run `bootc image copy-to-storage --help` for more options. +## Mounting deployment image content + +`bootc image mount` exposes a path from the locally available booted or staged +deployment image at an existing empty directory. The source must be a directory; +`--source /` selects the image root. It refuses target paths with symlink +components and targets that are already mountpoints. It does not pull an image +or merge deployment state: `/var` is never mounted from host state, and OSTree +checkout fallbacks use the image's pristine `/usr/etc` rather than deployment-local +`/etc`. The target is checked immediately before attachment; callers must prevent +concurrent changes to the target directory while the command runs. + +```sh +bootc image mount --deployment booted --source /usr --target /mnt/booted-usr +bootc image unmount /mnt/booted-usr +``` + +Use `--deployment staged` to select the pending deployment from the active +backend: OSTree selects its staged deployment, while composefs reads the +existing `/run/composefs/staged-deployment` selector. Staged selection does not +discover transitions across backends. If the active backend has no staged +deployment, the command fails instead of guessing an image. This lookup does +not initialize storage, migrate repository metadata, configure the ESP, or +change mount namespaces. + +The mounted content is read-only, `nosuid`, and `nodev`. It intentionally +remains executable: the mount does not use `noexec`, so direct executable +paths in the mounted image can be run. The image root itself (`--source /`) is +a valid directory source. Read-only, `nosuid`, `nodev`, and `nosymfollow` do not +disable execution. However, `nosymfollow` prevents following symlinks in the +mounted tree, so a symlink path to an executable will not resolve, even when +its target is also inside the image. The requested source path itself must +also contain no symlink components; this prevents source lookup from escaping +the image before the mount is created. Unmount is non-recursive and operates on +the opened target; it fails if the target is not a mount root or if child mounts +prevent a safe unmount. `bootc image unmount TARGET` is a general-purpose +`umount TARGET`: it unmounts the named non-root mount, whether or not bootc +created it. It does not track or verify bootc provenance, so take care to name +only the mount you intend to remove. Mount attachment follows the propagation +behavior of the target mount namespace; use a private mount namespace if +propagation is undesired. + Example workflow: ``` @@ -25,4 +66,3 @@ FROM localhost/bootc $ podman build -t localhost/bootc-custom . $ bootc switch --transport containers-storage localhost/bootc-custom ``` - diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index bc150a0b70..cf321b6c0b 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -428,4 +428,11 @@ execute: test: - /tmt/tests/tests/test-63-install-shared-propagation extra-fixme_skip_if_composefs: true + +/plan-64-image-mount: + summary: Verify booted and staged deployment image mounts + discover: + how: fmf + test: + - /tmt/tests/tests/test-64-image-mount # END GENERATED PLANS diff --git a/tmt/tests/booted/test-64-image-mount.nu b/tmt/tests/booted/test-64-image-mount.nu new file mode 100644 index 0000000000..ed54bd4d68 --- /dev/null +++ b/tmt/tests/booted/test-64-image-mount.nu @@ -0,0 +1,116 @@ +# number: 64 +# tmt: +# summary: Verify booted and staged deployment image mounts +# duration: 10m + +use std assert + +def assert_mount_options [target] { + let options = ( + findmnt --mountpoint $target --json + | from json + | get filesystems.0.options + | split row "," + ) + # findmnt's options field reports these VFS mount options, including nosymfollow. + for option in [ro nosuid nodev nosymfollow] { + assert ( + $options | any { |value| $value == $option } + ) $"($target) should have the ($option) mount option" + } + assert (not ($options | any { |value| $value == "noexec" })) $"($target) should not have noexec" +} + +def cleanup_targets [targets] { + $targets | each { |target| + let result = (do -i { bootc image unmount $target } | complete) + if $result.exit_code != 0 { + $"($target): unmount failed: ($result.stderr); mount target left in place" + } + } | where { |failure| $failure != null } +} + +def mount_image [deployment source target] { + let result = (do -i { + bootc image mount --deployment $deployment --source $source --target $target + } | complete) + if $result.exit_code != 0 { + error make { + msg: $"Failed to mount ($deployment) image source ($source) at ($target): ($result.stderr)" + } + } +} + +def verify_image_mounts [base] { + let root_target = ($base | path join "root") + let usr_target = ($base | path join "usr") + let staged_target = ($base | path join "staged") + mut mounted = [] + let result = try { + mkdir $root_target $usr_target $staged_target + + mount_image booted / $root_target + $mounted = ($mounted | append $root_target) + mount_image booted /usr $usr_target + $mounted = ($mounted | append $usr_target) + + let root_os_release = ($root_target | path join "usr/lib/os-release") + let usr_os_release = ($usr_target | path join "lib/os-release") + assert ($root_os_release | path exists) "booted root mount should contain usr/lib/os-release" + assert ($usr_os_release | path exists) "/usr mount should contain lib/os-release" + assert equal (open $root_os_release | into string) (open $usr_os_release | into string) + + assert_mount_options $root_target + assert_mount_options $usr_target + + let write_path = ($usr_target | path join ".bootc-image-mount-write-test") + let write_result = (do -i { touch $write_path } | complete) + assert ($write_result.exit_code != 0) "writes to mounted image content should fail" + assert (not ($write_path | path exists)) "failed write should not create a file" + + # The mount is intentionally not noexec; verify an image executable runs. + let true_path = ($usr_target | path join "bin/true") + let executable_result = (do -i { ^$true_path } | complete) + assert equal $executable_result.exit_code 0 "mounted /usr/bin/true should be executable" + + let status = (bootc status --json | from json) + if (($status.status.staged? | default null) != null) { + mount_image staged /usr $staged_target + $mounted = ($mounted | append $staged_target) + let staged_os_release = ($staged_target | path join "lib/os-release") + assert ($staged_os_release | path exists) "staged /usr mount should contain lib/os-release" + assert_mount_options $staged_target + } else { + print "SKIP: staged image-mount coverage unavailable (active backend reports no staged deployment)" + } + true + } catch { |error| $error } + + { + result: $result + mounted: $mounted + } +} + +let base = (^mktemp -d /run/bootc-image-mount.XXXXXX | str trim) +let test_outcome = (verify_image_mounts $base) +let mount_cleanup_errors = (cleanup_targets $test_outcome.mounted) +let cleanup_errors = if (($mount_cleanup_errors | length) > 0) { + $mount_cleanup_errors | append $"($base): directory retained because a mount could not be unmounted" +} else { + let remove_result = (do -i { rm --recursive $base } | complete) + if $remove_result.exit_code == 0 { + [] + } else { + [$"($base): failed to remove test directory: ($remove_result.stderr)"] + } +} + +if (($cleanup_errors | length) > 0) { + error make { + msg: $"Failed to clean up image mounts: ($cleanup_errors); test result: ($test_outcome.result)" + } +} +if $test_outcome.result != true { + error make { msg: $"Image mount assertions failed: ($test_outcome.result)" } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 7b7c804992..f0d31f08a5 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -276,3 +276,8 @@ check: summary: Install from a shared mount namespace without leaking the storage bind mount duration: 30m test: nu booted/test-install-shared-propagation.nu + +/test-64-image-mount: + summary: Verify booted and staged deployment image mounts + duration: 10m + test: nu booted/test-64-image-mount.nu