Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/initramfs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ fn mount_setattr(fd: impl AsFd, flags: libc::c_int, attr: &MountAttr) -> Result<
Ok(())
}

/// Set mount to readonly
/// Set a detached mount tree read-only without reopening its path.
#[context("Setting mount readonly")]
fn set_mount_readonly(fd: impl AsFd) -> Result<()> {
pub fn set_mount_readonly(fd: impl AsFd) -> Result<()> {
let attr = MountAttr {
attr_set: MOUNT_ATTR_RDONLY,
attr_clr: 0,
Expand Down
5 changes: 4 additions & 1 deletion crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ pub(crate) struct UsrOverlayOpts {

#[derive(Debug, clap::Subcommand, PartialEq, Eq)]
pub(crate) enum InstallOpts {
/// Mount an installed deployment into a caller-owned directory.
Mount(crate::mount::MountOpts),
/// Install to the target block device.
///
/// This command must be invoked inside of the container, which will be
Expand Down Expand Up @@ -693,7 +695,7 @@ pub(crate) enum SelinuxOpts {
},
}

fn parse_absolute_path(value: &str) -> std::result::Result<Utf8PathBuf, String> {
pub(crate) fn parse_absolute_path(value: &str) -> std::result::Result<Utf8PathBuf, String> {
let path = Utf8PathBuf::from(value);
if path.is_absolute() {
Ok(path)
Expand Down Expand Up @@ -2241,6 +2243,7 @@ async fn run_from_opt(opt: Opt) -> Result<CliExitStatus> {
}
},
Opt::Install(opts) => match opts {
InstallOpts::Mount(opts) => crate::mount::mount(opts).await,
#[cfg(feature = "install-to-disk")]
InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,
InstallOpts::ToFilesystem(opts) => {
Expand Down
1 change: 1 addition & 0 deletions crates/lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ mod lints;
mod loader_entries;
mod lsm;
pub(crate) mod metadata;
mod mount;
mod parsers;
mod podman;
pub(crate) mod podman_client;
Expand Down
336 changes: 336 additions & 0 deletions crates/lib/src/mount.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
//! Explicit, caller-owned mounts of an offline deployment.
//!
//! The caller owns the mount namespace and is responsible for cleaning up the
//! resulting tree, normally with `umount -R`. Installation commands already
//! run in a private mount namespace, so bootc does not persist mount state or
//! try to reconstruct teardown across processes.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail, ensure};
use camino::Utf8PathBuf;
use cap_std_ext::{
cap_std::{ambient_authority, fs::Dir},
dirext::CapStdExtDirExt,
};
use clap::Args;
use ostree::gio;
use ostree_ext::ostree;
use rustix::fs::{Mode, OFlags, openat};
use rustix::mount::{MoveMountFlags, OpenTreeFlags, move_mount, open_tree};

const ETC: &str = "etc";
const VAR: &str = "var";

fn ostree_state_path(stateroot: &str) -> PathBuf {
Path::new("ostree/deploy").join(stateroot).join(VAR)
}

#[derive(Debug, Args, PartialEq, Eq)]
pub(crate) struct MountOpts {
/// Offline target sysroot.
#[clap(long, value_parser = crate::cli::parse_absolute_path)]
pub(crate) sysroot: Utf8PathBuf,

/// Make the persistent /etc and /var mounts writable. The deployment root remains read-only.
#[clap(long)]
pub(crate) writable: bool,

/// Empty directory receiving the deployment mount.
pub(crate) target: Utf8PathBuf,
}

/// Validate the path relationship before doing any mount syscall.
pub(crate) fn validate_mount_target(sysroot: &Path, target: &Path) -> Result<()> {
ensure!(sysroot.is_absolute(), "--sysroot must be absolute");
ensure!(target.is_absolute(), "mount target must be absolute");
let target_metadata = std::fs::symlink_metadata(target)
.with_context(|| format!("Opening mount target {}", target.display()))?;
ensure!(
target_metadata.file_type().is_dir(),
"mount target must be a real directory: {}",
target.display()
);
ensure!(
target.read_dir()?.next().is_none(),
"mount target must be an existing empty directory: {}",
target.display()
);

let sysroot = std::fs::canonicalize(sysroot)
.with_context(|| format!("Resolving sysroot {}", sysroot.display()))?;
let target = std::fs::canonicalize(target)
.with_context(|| format!("Resolving mount target {}", target.display()))?;
ensure!(
!target.starts_with(&sysroot) && !sysroot.starts_with(&target),
"sysroot and mount target must not overlap"
);
Ok(())
}

fn validate_relative_mount_path(path: &Path) -> Result<()> {
ensure!(!path.is_absolute(), "path must be relative to the sysroot");
ensure!(!path.as_os_str().is_empty(), "path must not be empty");
for component in path.components() {
ensure!(
matches!(component, std::path::Component::Normal(_)),
"path must contain only normal components"
);
}
Ok(())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeploymentBackend {
Ostree,
Composefs,
}

fn select_backend(
ostree_deployments: usize,
composefs_deployments: usize,
) -> Result<DeploymentBackend> {
if ostree_deployments > 1 {
bail!("target must contain exactly one deployment; refusing ambiguous selection");
}
if composefs_deployments > 1 {
bail!("target must contain exactly one composefs deployment; refusing ambiguous selection");
}
match (ostree_deployments, composefs_deployments) {
(1, 0) => Ok(DeploymentBackend::Ostree),
(0, 1) => Ok(DeploymentBackend::Composefs),
(1, 1) => bail!("target has ambiguous OSTree and composefs deployments"),
(0, 0) => bail!("target contains no deployment"),
_ => unreachable!(),
}
}

pub(crate) async fn mount(mut opts: MountOpts) -> Result<()> {
validate_mount_target(opts.sysroot.as_std_path(), opts.target.as_std_path())?;
opts.sysroot = canonicalize_utf8_path(&opts.sysroot, "target sysroot")?;
opts.target = canonicalize_utf8_path(&opts.target, "mount target")?;

let sysroot_dir = Dir::open_ambient_dir(&opts.sysroot, ambient_authority())
.context("Opening target sysroot directory")?;
let target_dir = Dir::open_ambient_dir(&opts.target, ambient_authority())
.context("Opening mount target directory")?;
ensure!(
target_dir.is_mountpoint(".")? != Some(true),
"mount target is already a mountpoint"
);
ensure!(
target_dir.entries()?.next().is_none(),
"mount target changed and is no longer empty"
);

let repo = open_optional_dir_nofollow(&sysroot_dir, Path::new("ostree/repo"))?;
let composefs = open_optional_dir_nofollow(&sysroot_dir, Path::new("composefs"))?;
let ostree_deployment_count = if repo.is_some() {
let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(&opts.sysroot)));
sysroot
.load(gio::Cancellable::NONE)
.context("Loading target OSTree sysroot")?;
sysroot.deployments().len()
} else {
0
};
let composefs_deployments = if composefs.is_some() {
open_optional_dir_nofollow(&sysroot_dir, Path::new("state/deploy"))?
.map(|deployments| composefs_deployments(&deployments))
.transpose()?
.unwrap_or_default()
} else {
Vec::new()
};
let backend = select_backend(ostree_deployment_count, composefs_deployments.len())?;

let (root_tree, etc_source, var_source, sysroot_lock) = match backend {
DeploymentBackend::Composefs => {
let [id] = composefs_deployments.as_slice() else {
unreachable!("composefs backend was selected without one deployment");
};
let deployments_dir = open_dir_nofollow(&sysroot_dir, Path::new("state/deploy"))?;
let deployment = open_dir_nofollow(&deployments_dir, Path::new(id))?;
let etc_source = open_dir_nofollow(&deployment, Path::new(ETC))?;
let state = open_dir_nofollow(&sysroot_dir, Path::new("state"))?;
let default = open_dir_nofollow(
&open_dir_nofollow(&state, Path::new("os"))?,
Path::new("default"),
)?;
let var_source = open_dir_nofollow(&default, Path::new(VAR))?;
let repo = crate::store::ComposefsRepository::open_path(&sysroot_dir, "composefs")
.context("Opening composefs repository")?;
let image = repo.mount(id).context("Mounting composefs image")?;
bootc_initramfs_setup::set_mount_readonly(&image)
.context("Making detached composefs root read-only")?;
(image, etc_source, var_source, None)
}
DeploymentBackend::Ostree => {
let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(&opts.sysroot)));
sysroot
.load(gio::Cancellable::NONE)
.context("Loading target OSTree sysroot")?;
let sysroot_lock = ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?;
let deployments = sysroot.deployments();
let [deployment] = deployments.as_slice() else {
bail!("target must contain exactly one deployment; refusing ambiguous selection");
};
let source = PathBuf::from(sysroot.deployment_dirpath(deployment).as_str());
validate_relative_mount_path(&source)
.with_context(|| format!("Invalid OSTree deployment path {source:?}"))?;
let var = ostree_state_path(deployment.stateroot().as_str());
validate_relative_mount_path(&var)
.with_context(|| format!("Invalid OSTree state path {var:?}"))?;
let deployment_dir = open_dir_nofollow(&sysroot_dir, &source)?;
let etc_source = open_dir_nofollow(&deployment_dir, Path::new(ETC))?;
let var_source = open_dir_nofollow(&sysroot_dir, &var)?;
let root_tree = open_tree(
&sysroot_dir,
&source,
OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
)?;
(root_tree, etc_source, var_source, Some(sysroot_lock))
}
};
let _sysroot_lock = sysroot_lock;

bootc_initramfs_setup::set_mount_readonly(&root_tree)
.context("Making detached deployment root read-only")?;
move_mount(
&root_tree,
"",
&target_dir,
".",
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
)?;
let assembly = (|| -> Result<()> {
let target_root = Dir::open_ambient_dir(&opts.target, ambient_authority())
.context("Opening mounted deployment root")?;
attach_state(&etc_source, &target_root, ETC, opts.writable)?;
attach_state(&var_source, &target_root, VAR, opts.writable)?;
Ok(())
})();
if let Err(error) = assembly {
let cleanup = bootc_mount::unmount_recursive(&opts.target);
return match cleanup {
Ok(()) => Err(error).context("Assembling offline deployment mount"),
Err(cleanup_error) => Err(error).context(format!(
"Assembling offline deployment mount (cleanup also failed: {cleanup_error})"
)),
};
}
Ok(())
}

fn composefs_deployments(path: &Dir) -> Result<Vec<String>> {
let mut ids = Vec::new();
for entry in path.entries().context("Reading composefs deployments")? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.as_bytes().len() == 128 && name.bytes().all(|byte| byte.is_ascii_hexdigit()) {
ensure!(
entry.file_type()?.is_dir(),
"composefs deployment is not a directory"
);
let deployment = open_dir_nofollow(path, Path::new(name))?;
open_dir_nofollow(&deployment, Path::new(ETC))
.context("composefs deployment has no /etc state")?;
ids.push(name.to_owned());
}
}
Ok(ids)
}

fn attach_state(source: &Dir, target: &Dir, name: &str, writable: bool) -> Result<()> {
let tree = open_tree(
source,
".",
OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
)?;
if !writable {
bootc_initramfs_setup::set_mount_readonly(&tree)
.context("Making detached state mount read-only")?;
}
move_mount(
&tree,
"",
target,
name,
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
)?;
Ok(())
}

fn open_dir_nofollow(parent: &Dir, path: &Path) -> Result<Dir> {
let mut current = parent.try_clone()?;
for component in path.components() {
let std::path::Component::Normal(name) = component else {
bail!("descriptor-relative paths must contain only normal components");
};
let fd = openat(
&current,
name,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
current = Dir::from_std_file(std::fs::File::from(fd));
}
Ok(current)
}

fn open_optional_dir_nofollow(parent: &Dir, path: &Path) -> Result<Option<Dir>> {
match open_dir_nofollow(parent, path) {
Ok(dir) => Ok(Some(dir)),
Err(error)
if error.downcast_ref::<rustix::io::Errno>() == Some(&rustix::io::Errno::NOENT) =>
{
Ok(None)
}
Err(error) => Err(error),
}
}

fn canonicalize_utf8_path(path: &Utf8PathBuf, description: &str) -> Result<Utf8PathBuf> {
let canonical =
std::fs::canonicalize(path).with_context(|| format!("Resolving {description} {path}"))?;
Utf8PathBuf::from_path_buf(canonical).map_err(|path| {
anyhow::anyhow!(
"Resolved {description} is not valid UTF-8: {}",
path.display()
)
})
}

#[cfg(test)]
mod tests {
use super::{DeploymentBackend, select_backend, validate_mount_target};

#[test]
fn selects_only_unambiguous_backend() {
let cases = [
(0, 0, None),
(1, 0, Some(DeploymentBackend::Ostree)),
(0, 1, Some(DeploymentBackend::Composefs)),
(1, 1, None),
(2, 0, None),
(0, 2, None),
];
for (ostree, composefs, expected) in cases {
assert_eq!(select_backend(ostree, composefs).ok(), expected);
}
}

#[test]
fn rejects_overlap_and_nonempty_targets() {
let temp = tempfile::tempdir().unwrap();
let sysroot = temp.path().join("sysroot");
let target = temp.path().join("target");
std::fs::create_dir(&sysroot).unwrap();
std::fs::create_dir(&target).unwrap();
validate_mount_target(&sysroot, &target).unwrap();
std::fs::create_dir(target.join("nested")).unwrap();
assert!(validate_mount_target(&sysroot, &target).is_err());
assert!(validate_mount_target(&sysroot, &sysroot).is_err());
}
}
7 changes: 7 additions & 0 deletions crates/mount/src/mount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,13 @@ pub fn mount_typed(dev: &str, fstype: &str, target: &Utf8Path) -> Result<()> {
.run_inherited_with_cmd_context()
}

/// Recursively unmount a tree assembled by a caller-owned operation.
pub fn unmount_recursive(target: &Utf8Path) -> Result<()> {
Command::new("umount")
.args(["--recursive", target.as_str()])
.run_inherited_with_cmd_context()
}

/// If the fsid of the passed path matches the fsid of the same path rooted
/// at /proc/1/root, it is assumed that these are indeed the same mounted
/// filesystem between container and host.
Expand Down
Loading
Loading