From 2e8ac9dd58dea44e253cd8fcd1abec91766adf50 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 10:53:43 -0400 Subject: [PATCH 1/5] capi: Enable the pre-6.15 mount compat by default libcomposefs is built on its own (`make install-capi`, which the RPM spec's `make install` runs), so it never gets the kernel compat features cfsctl and composefs-setup-root enable by default. Without them, lcfs_mount_image() passes the detached EROFS mount to overlayfs by fd, which kernels before 6.15 reject with EBADF. ostree-prepare-root on CentOS Stream 10 (6.12) fails to mount the deployment's composefs image that way. The C library works on older kernels at runtime; give the Rust one the same features and defaults as the other binaries. Generated-by: AI --- crates/composefs-capi/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/composefs-capi/Cargo.toml b/crates/composefs-capi/Cargo.toml index 68a2da3f..c8966b47 100644 --- a/crates/composefs-capi/Cargo.toml +++ b/crates/composefs-capi/Cargo.toml @@ -9,6 +9,13 @@ description = "C-compatible shared library (libcomposefs) backed by Rust compose [lib] crate-type = ["cdylib", "staticlib"] +[features] +# Like the other binaries, support older kernels by default; the C +# library handled them at runtime. +default = ['pre-6.15'] +rhel9 = ['composefs/rhel9'] +'pre-6.15' = ['composefs/pre-6.15'] + [dependencies] composefs = { workspace = true } libc = "0.2" From 2e4a8e8c6dfa92ce412d92cbefe7daccf035b2ef Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 10:53:43 -0400 Subject: [PATCH 2/5] capi: Don't mount the EROFS image twice in lcfs_mount_fd() With object directories, lcfs_mount_fd() mounted the EROFS image and then passed that mount to composefs_fsmount(), which expects the image file and mounts it again. The second mount gets a directory as its source and fails with ENOTBLK, so every composefs mount with a basedir failed. ostree-prepare-root hits this on boot ("composefs: failed to mount: Block device required") and drops to the emergency shell. Only mount the EROFS image ourselves when there's no overlay on top. Generated-by: AI --- crates/composefs-capi/src/mount.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/composefs-capi/src/mount.rs b/crates/composefs-capi/src/mount.rs index 3309ce7e..0868a5ad 100644 --- a/crates/composefs-capi/src/mount.rs +++ b/crates/composefs-capi/src/mount.rs @@ -77,14 +77,6 @@ pub unsafe extern "C" fn lcfs_mount_fd( } let image_fd = OwnedFd::from_raw_fd(dup_fd); - let erofs_fd = match composefs::mount::erofs_mount(image_fd) { - Ok(fd) => fd, - Err(e) => { - set_errno(io_error_to_errno(&e)); - return -1; - } - }; - let mut basedirs: Vec = Vec::new(); if !options.is_null() { let opts = &*options; @@ -141,8 +133,9 @@ pub unsafe extern "C" fn lcfs_mount_fd( } } + // composefs_fsmount() mounts the EROFS image itself. match composefs::mount::composefs_fsmount( - erofs_fd, + image_fd, "composefs", &borrowed, verity, @@ -160,6 +153,13 @@ pub unsafe extern "C" fn lcfs_mount_fd( } } } else { + let erofs_fd = match composefs::mount::erofs_mount(image_fd) { + Ok(fd) => fd, + Err(e) => { + set_errno(io_error_to_errno(&e)); + return -1; + } + }; if let Err(e) = composefs::mount::mount_at(&erofs_fd, CWD, mountpoint_cstr) { set_errno(e.raw_os_error()); return -1; From 9eb5db01cf596278cb84655ec30196a6fffa3763 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 10:53:43 -0400 Subject: [PATCH 3/5] capi: Check expected_fsverity_digest in lcfs_mount_fd() lcfs_mount_fd() ignored expected_fsverity_digest and mounted whatever image it was given. The C library measures the image's fs-verity digest and refuses to mount it on a mismatch, and callers rely on that: ostree-prepare-root passes the digest of the deployment's composefs image, so with our library its verification silently didn't happen. Check it the way C does: the kernel's sha256 fs-verity measurement of the image must equal the expected digest, failing with EWRONGVERITY (EILSEQ) if it doesn't and ENOVERITY (ENOTTY) if the image has no fs-verity. Also validate the options up front as C does, so an unparseable digest, unknown flags, an upperdir without a workdir (or the reverse) and IDMAP without an fd fail with EINVAL before anything is opened or mounted. Generated-by: AI --- crates/composefs-capi/src/mount.rs | 124 +++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/composefs-capi/src/mount.rs b/crates/composefs-capi/src/mount.rs index 0868a5ad..c3d06a91 100644 --- a/crates/composefs-capi/src/mount.rs +++ b/crates/composefs-capi/src/mount.rs @@ -1,5 +1,5 @@ use std::ffi::{CStr, CString, c_char, c_int}; -use std::os::fd::{AsFd, FromRawFd, OwnedFd}; +use std::os::fd::{AsFd, BorrowedFd, FromRawFd, OwnedFd}; use libc::size_t; use rustix::fs::{CWD, Mode, OFlags, open}; @@ -23,6 +23,95 @@ pub struct LcfsMountOptions { const LCFS_MOUNT_FLAGS_REQUIRE_VERITY: u32 = 1 << 0; const LCFS_MOUNT_FLAGS_IDMAP: u32 = 1 << 3; const LCFS_MOUNT_FLAGS_TRY_VERITY: u32 = 1 << 4; +const LCFS_MOUNT_FLAGS_MASK: u32 = (1 << 5) - 1; + +/// `EWRONGVERITY` from lcfs-mount.h: the image's fs-verity digest doesn't +/// match `expected_fsverity_digest`. +const EWRONGVERITY: c_int = libc::EILSEQ; +/// `ENOVERITY` from lcfs-mount.h: the image has no fs-verity digest. +const ENOVERITY: c_int = libc::ENOTTY; +/// Longest digest accepted in `expected_fsverity_digest`, as in C. +const MAX_DIGEST_SIZE: usize = 64; + +/// Parses a hex digest; None if it isn't an even number of hex digits +/// of at most [`MAX_DIGEST_SIZE`] bytes. +/// +/// An empty string parses to an empty digest, which then never matches. +/// That's deliberately stricter than C, which treats an empty digest as +/// no digest and mounts without checking. +fn parse_hex_digest(hex: &[u8]) -> Option> { + if !hex.len().is_multiple_of(2) || hex.len() / 2 > MAX_DIGEST_SIZE { + return None; + } + // Digit by digit: u8::from_str_radix() would also accept e.g. "+a". + let digit = |c: u8| char::from(c).to_digit(16); + hex.chunks(2) + .map(|pair| Some((digit(pair[0])? << 4 | digit(pair[1])?) as u8)) + .collect() +} + +/// Checks the options like the C library does before mounting, returning +/// the parsed expected image digest, if any, or an errno. +/// +/// # Safety +/// +/// `options` must be null or point to valid mount options. +unsafe fn validate_options(options: *const LcfsMountOptions) -> Result>, c_int> { + let Some(opts) = (unsafe { options.as_ref() }) else { + return Ok(None); + }; + if opts.flags & !LCFS_MOUNT_FLAGS_MASK != 0 + || opts.upperdir.is_null() != opts.workdir.is_null() + || (opts.flags & LCFS_MOUNT_FLAGS_IDMAP != 0 && opts.idmap_fd < 0) + { + return Err(libc::EINVAL); + } + if opts.expected_fsverity_digest.is_null() { + return Ok(None); + } + let hex = unsafe { CStr::from_ptr(opts.expected_fsverity_digest) }; + parse_hex_digest(hex.to_bytes()) + .map(Some) + .ok_or(libc::EINVAL) +} + +/// Checks the image's fs-verity digest (as measured by the kernel, like +/// the C library) against the expected one. +fn verify_image_digest(image: BorrowedFd<'_>, expected: &[u8]) -> Result<(), c_int> { + use composefs::fsverity::{MeasureVerityError, Sha256HashValue, measure_verity}; + use zerocopy::IntoBytes; + + let found = measure_verity::(image).map_err(|e| match e { + MeasureVerityError::VerityMissing | MeasureVerityError::FilesystemNotSupported => ENOVERITY, + MeasureVerityError::InvalidDigestAlgorithm { .. } + | MeasureVerityError::InvalidDigestSize { .. } => EWRONGVERITY, + MeasureVerityError::Io(e) => match e.raw_os_error() { + Some(libc::ENODATA | libc::EOPNOTSUPP | libc::ENOTTY) => ENOVERITY, + Some(errno) => errno, + None => libc::EIO, + }, + })?; + if found.as_bytes() == expected { + Ok(()) + } else { + Err(EWRONGVERITY) + } +} + +/// Opens a directory given in the mount options. +/// +/// # Safety +/// +/// `path` must be a valid C string. +unsafe fn open_dir(path: *const c_char, flags: OFlags) -> Result { + let path = unsafe { CStr::from_ptr(path) }; + open( + path, + flags | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|e| e.raw_os_error()) +} fn io_error_to_errno(e: &std::io::Error) -> c_int { e.raw_os_error().unwrap_or(libc::EINVAL) @@ -39,6 +128,12 @@ pub unsafe extern "C" fn lcfs_mount_image( return -1; } + // Like C, reject bad options before touching the image. + if let Err(errno) = unsafe { validate_options(options) } { + set_errno(errno); + return -1; + } + unsafe { let path_cstr = CStr::from_ptr(path); @@ -68,6 +163,14 @@ pub unsafe extern "C" fn lcfs_mount_fd( return -1; } + let expected_digest = match unsafe { validate_options(options) } { + Ok(digest) => digest, + Err(errno) => { + set_errno(errno); + return -1; + } + }; + unsafe { let mountpoint_cstr = CStr::from_ptr(mountpoint); @@ -77,6 +180,15 @@ pub unsafe extern "C" fn lcfs_mount_fd( } let image_fd = OwnedFd::from_raw_fd(dup_fd); + // Callers such as ostree-prepare-root rely on this check to only + // mount the image they expect. + if let Some(expected) = expected_digest + && let Err(errno) = verify_image_digest(image_fd.as_fd(), &expected) + { + set_errno(errno); + return -1; + } + let mut basedirs: Vec = Vec::new(); if !options.is_null() { let opts = &*options; @@ -106,14 +218,10 @@ pub unsafe extern "C" fn lcfs_mount_fd( if !basedirs.is_empty() { let mut basedir_fds: Vec = Vec::new(); for dir in &basedirs { - match open( - dir.as_c_str(), - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, - Mode::empty(), - ) { + match open_dir(dir.as_ptr(), OFlags::RDONLY) { Ok(fd) => basedir_fds.push(fd), - Err(e) => { - set_errno(e.raw_os_error()); + Err(errno) => { + set_errno(errno); return -1; } } From cbfc10de44196489873e34802147777256a39858 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 10:53:43 -0400 Subject: [PATCH 4/5] capi: Support upperdir and workdir in lcfs_mount_fd() lcfs_mount_fd() ignored upperdir and workdir, so a caller asking for a writable composefs (ostree does, for a transient root) got a read-only mount without its upper layer. Pass them to the overlayfs mount, and like C make the mount writable unless LCFS_MOUNT_FLAGS_READONLY is set. Generated-by: AI --- crates/composefs-capi/src/mount.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/composefs-capi/src/mount.rs b/crates/composefs-capi/src/mount.rs index c3d06a91..cbe52333 100644 --- a/crates/composefs-capi/src/mount.rs +++ b/crates/composefs-capi/src/mount.rs @@ -21,6 +21,7 @@ pub struct LcfsMountOptions { } const LCFS_MOUNT_FLAGS_REQUIRE_VERITY: u32 = 1 << 0; +const LCFS_MOUNT_FLAGS_READONLY: u32 = 1 << 1; const LCFS_MOUNT_FLAGS_IDMAP: u32 = 1 << 3; const LCFS_MOUNT_FLAGS_TRY_VERITY: u32 = 1 << 4; const LCFS_MOUNT_FLAGS_MASK: u32 = (1 << 5) - 1; @@ -232,6 +233,23 @@ pub unsafe extern "C" fn lcfs_mount_fd( if !options.is_null() { let opts = &*options; + // validate_options() checked that both or neither are set. + if !opts.upperdir.is_null() { + let dirs = open_dir(opts.upperdir, OFlags::PATH) + .and_then(|upper| Ok((upper, open_dir(opts.workdir, OFlags::PATH)?))); + match dirs { + Ok((upper, work)) => { + mount_options.set_overlay(upper, work); + } + Err(errno) => { + set_errno(errno); + return -1; + } + } + // As in C, a mount with an upper layer is writable + // unless asked otherwise. + mount_options.set_read_write(opts.flags & LCFS_MOUNT_FLAGS_READONLY == 0); + } if (opts.flags & LCFS_MOUNT_FLAGS_IDMAP) != 0 && opts.idmap_fd >= 0 { let dup_idmap = libc::dup(opts.idmap_fd); if dup_idmap < 0 { From f1752b514c169301eb151b53cba6912758f1fff3 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 25 Sep 2026 10:53:43 -0400 Subject: [PATCH 5/5] tests: Add privileged tests of lcfs_mount_image() Nothing tested mounting through the C API: the capi CI job runs the C test suite, which doesn't mount anything. So lcfs_mount_image() failed for every image with an object directory, and ignored expected_fsverity_digest, without any test noticing. The test image now also builds our libcomposefs and lcfs-mount-test, a small C program that mounts an image the way ostree-prepare-root does. The tests mount an image from a verity-enabled repository and read back an inline and an external file, with and without the correct digest; check the errors for a wrong digest, an unparseable one and an image without fs-verity; and check that an upper layer is writable, or read-only with LCFS_MOUNT_FLAGS_READONLY. Generated-by: AI --- Containerfile | 21 +- crates/composefs-capi/tests/lcfs-mount-test.c | 68 +++++ .../src/tests/capi.rs | 241 ++++++++++++++++++ .../src/tests/mod.rs | 1 + .../src/tests/privileged.rs | 6 +- 5 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 crates/composefs-capi/tests/lcfs-mount-test.c create mode 100644 crates/composefs-integration-tests/src/tests/capi.rs diff --git a/Containerfile b/Containerfile index 0c838232..a789bd93 100644 --- a/Containerfile +++ b/Containerfile @@ -36,17 +36,30 @@ RUN --mount=type=cache,target=/src/target \ --mount=type=cache,target=/root/.cargo/git \ cargo fetch -# Build cfsctl and integration test binary -# Two separate invocations: features are scoped to composefs-ctl and must not +# Build cfsctl, the integration test binary and libcomposefs. +# Separate invocations: features are scoped to composefs-ctl and must not # be passed to composefs-integration-tests, which has no optional features. +# libcomposefs only gets rhel9 (pre-6.15 is its default). RUN --network=none \ --mount=type=cache,target=/src/target \ --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/root/.cargo/git \ cargo build --release -p composefs-ctl --features="${cfsctl_features}" && \ cargo build --release -p composefs-integration-tests && \ + capi_features=$(echo "${cfsctl_features}" | tr ', ' '\n\n' | grep -x rhel9 || true) && \ + cargo build --release -p composefs-capi --features="${capi_features}" && \ cp /src/target/release/cfsctl /usr/bin/cfsctl && \ - cp /src/target/release/cfsctl-integration-tests /usr/bin/cfsctl-integration-tests + cp /src/target/release/cfsctl-integration-tests /usr/bin/cfsctl-integration-tests && \ + mkdir -p /usr/lib/composefs-rs-test && \ + cp /src/target/release/libcomposefs_capi.so /usr/lib/composefs-rs-test/libcomposefs.so.1 + +# A C program calling our libcomposefs (not the distribution's), for the +# privileged libcomposefs tests +RUN --network=none \ + ln -s libcomposefs.so.1 /usr/lib/composefs-rs-test/libcomposefs.so && \ + gcc -o /usr/bin/lcfs-mount-test /src/crates/composefs-capi/tests/lcfs-mount-test.c \ + -I/src/crates/composefs-capi/include -L/usr/lib/composefs-rs-test -lcomposefs \ + -Wl,-rpath,/usr/lib/composefs-rs-test # -- final bootable image -- FROM ${base_image} @@ -56,3 +69,5 @@ RUN /src/contrib/packaging/install-test-deps.sh && rm -rf /src COPY --from=build /usr/bin/cfsctl /usr/bin/cfsctl COPY --from=build /usr/bin/cfsctl-integration-tests /usr/bin/cfsctl-integration-tests +COPY --from=build /usr/lib/composefs-rs-test /usr/lib/composefs-rs-test +COPY --from=build /usr/bin/lcfs-mount-test /usr/bin/lcfs-mount-test diff --git a/crates/composefs-capi/tests/lcfs-mount-test.c b/crates/composefs-capi/tests/lcfs-mount-test.c new file mode 100644 index 00000000..7efa9983 --- /dev/null +++ b/crates/composefs-capi/tests/lcfs-mount-test.c @@ -0,0 +1,68 @@ +/* Mount a composefs image with lcfs_mount_image(), the way C consumers + * such as ostree-prepare-root do. Used by the privileged integration + * tests to exercise the Rust libcomposefs. + * + * Usage: lcfs-mount-test [-d DIGEST] [-u UPPERDIR -w WORKDIR [-r]] IMAGE MOUNTPOINT OBJDIR + * + * The mount is read-only unless there's an upper directory; -r makes it + * read-only then too. + * + * On failure, prints the error and exits with errno as the status, so + * callers can check which error the library reported. + * + * SPDX-License-Identifier: MIT OR Apache-2.0 + */ +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + struct lcfs_mount_options_s options = { 0 }; + const char *objdirs[1]; + bool readonly = false; + int opt; + + options.idmap_fd = -1; + options.flags = LCFS_MOUNT_FLAGS_READONLY; + while ((opt = getopt(argc, argv, "d:u:w:r")) != -1) { + switch (opt) { + case 'd': + options.expected_fsverity_digest = optarg; + break; + case 'u': + options.upperdir = optarg; + options.flags &= ~LCFS_MOUNT_FLAGS_READONLY; + break; + case 'w': + options.workdir = optarg; + break; + case 'r': + readonly = true; + break; + default: + fprintf(stderr, "usage: %s [-d DIGEST] [-u UPPERDIR -w WORKDIR [-r]] IMAGE MOUNTPOINT OBJDIR\n", + argv[0]); + return 2; + } + } + if (readonly) + options.flags |= LCFS_MOUNT_FLAGS_READONLY; + if (argc - optind != 3) { + fprintf(stderr, "expected IMAGE MOUNTPOINT OBJDIR\n"); + return 2; + } + + objdirs[0] = argv[optind + 2]; + options.objdirs = objdirs; + options.n_objdirs = 1; + if (lcfs_mount_image(argv[optind], argv[optind + 1], &options) < 0) { + int errsv = errno; + fprintf(stderr, "lcfs_mount_image: %s\n", strerror(errsv)); + return errsv; + } + return 0; +} diff --git a/crates/composefs-integration-tests/src/tests/capi.rs b/crates/composefs-integration-tests/src/tests/capi.rs new file mode 100644 index 00000000..6073d375 --- /dev/null +++ b/crates/composefs-integration-tests/src/tests/capi.rs @@ -0,0 +1,241 @@ +//! Privileged tests of our libcomposefs through its C API. +//! +//! They run `lcfs-mount-test` (crates/composefs-capi/tests/lcfs-mount-test.c), +//! which the test image builds against our libcomposefs, and mounts images +//! the way ostree-prepare-root does: `lcfs_mount_image()` with an object +//! directory and optionally an expected fs-verity digest. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use rustix::io::Errno; +use xshell::{Shell, cmd}; + +use crate::tests::privileged::{VerityTempDir, require_privileged}; +use crate::{cfsctl, integration_test}; + +/// The C test program; overridable for running outside the test image. +fn lcfs_mount_test() -> String { + std::env::var("LCFS_MOUNT_TEST_PATH").unwrap_or_else(|_| "lcfs-mount-test".into()) +} + +/// lcfs-mount.h's `EWRONGVERITY`: the image digest doesn't match. +const EWRONGVERITY: Errno = Errno::ILSEQ; +/// lcfs-mount.h's `ENOVERITY`: the image has no fs-verity digest. +const ENOVERITY: Errno = Errno::NOTTY; + +/// Larger than the inline threshold, so it's stored as an external object. +const LARGE_FILE_SIZE: usize = 64 * 1024; + +/// A composefs image in a verity-enabled repository, with its source tree. +struct TestImage { + dir: VerityTempDir, + image: PathBuf, + digest: String, +} + +impl TestImage { + fn new(sh: &Shell) -> Result { + let cfsctl = cfsctl()?; + let dir = VerityTempDir::new()?; + let repo = dir.path().join("repo"); + let rootfs = dir.path().join("rootfs"); + // create-image needs a /usr + std::fs::create_dir_all(rootfs.join("usr/sub"))?; + std::fs::write(rootfs.join("usr/sub/small"), "hello\n")?; + std::fs::write(rootfs.join("usr/large"), large_content())?; + + // The C API measures images with sha256 fs-verity. + cmd!( + sh, + "{cfsctl} --repo {repo} init --algorithm fsverity-sha256-12" + ) + .run()?; + let output = cmd!(sh, "{cfsctl} --repo {repo} create-image {rootfs}").read()?; + let digest = output + .trim() + .strip_prefix("sha256:") + .with_context(|| format!("unexpected image ID: {output}"))? + .to_string(); + let image = repo.join("images").join(&digest); + ensure!(image.exists(), "no image at {}", image.display()); + Ok(Self { dir, image, digest }) + } + + fn objects(&self) -> PathBuf { + self.dir.path().join("repo/objects") + } + + fn scratch(&self, name: &str) -> Result { + let path = self.dir.path().join(name); + std::fs::create_dir_all(&path)?; + Ok(path) + } +} + +fn large_content() -> Vec { + (0..LARGE_FILE_SIZE).map(|i| (i % 251) as u8).collect() +} + +/// Unmounts its mountpoint when dropped, if it's mounted, so a failed +/// assertion doesn't leave a mount behind. +struct Unmount(PathBuf); + +impl Drop for Unmount { + fn drop(&mut self) { + let path = &self.0; + if let Ok(sh) = Shell::new() + && is_mounted(&sh, path) + { + let _ = cmd!(sh, "umount {path}").quiet().run(); + } + } +} + +/// Runs lcfs-mount-test, returning its exit status (the errno on failure) +/// and a guard that unmounts the mountpoint again. +fn mount(image: &TestImage, mountpoint: &Path, args: &[&str]) -> Result<(i32, Unmount)> { + let guard = Unmount(mountpoint.to_path_buf()); + let status = std::process::Command::new(lcfs_mount_test()) + .args(args) + .arg(&image.image) + .arg(mountpoint) + .arg(image.objects()) + .status() + .context("running lcfs-mount-test")?; + let code = status + .code() + .with_context(|| format!("lcfs-mount-test killed: {status}"))?; + Ok((code, guard)) +} + +fn assert_content(mountpoint: &Path) -> Result<()> { + assert_eq!( + std::fs::read_to_string(mountpoint.join("usr/sub/small"))?, + "hello\n" + ); + assert!(std::fs::read(mountpoint.join("usr/large"))? == large_content()); + Ok(()) +} + +fn is_mounted(sh: &Shell, mountpoint: &Path) -> bool { + cmd!(sh, "mountpoint -q {mountpoint}").quiet().run().is_ok() +} + +fn privileged_capi_mount_image() -> Result<()> { + if require_privileged("privileged_capi_mount_image")?.is_some() { + return Ok(()); + } + let sh = Shell::new()?; + let image = TestImage::new(&sh)?; + let mnt = image.scratch("mnt")?; + + // Without and with the image's own digest + let digest_args = ["-d", image.digest.as_str()]; + for args in [&[][..], &digest_args] { + let (status, mounted) = mount(&image, &mnt, args)?; + assert_eq!(status, 0, "mount with {args:?}"); + assert_content(&mnt)?; + drop(mounted); + assert!(!is_mounted(&sh, &mnt)); + } + Ok(()) +} +integration_test!(privileged_capi_mount_image); + +/// `expected_fsverity_digest` must be enforced: ostree-prepare-root +/// relies on it to only mount the image it expects. +fn privileged_capi_mount_wrong_digest() -> Result<()> { + if require_privileged("privileged_capi_mount_wrong_digest")?.is_some() { + return Ok(()); + } + let sh = Shell::new()?; + let image = TestImage::new(&sh)?; + let mnt = image.scratch("mnt")?; + + // Flip the last hex digit. + let mut wrong = image.digest.clone(); + let last = wrong.pop().context("empty digest")?; + wrong.push(if last == '0' { '1' } else { '0' }); + + let plus_digest = format!("+{}", &image.digest[1..]); + + // (digest, errno), as the C library reports them + let cases = [ + (wrong.as_str(), EWRONGVERITY), + ("not-hex", Errno::INVAL), + (&image.digest[..10], EWRONGVERITY), + // Rejected like any other non-hex digit, as in C + (&plus_digest, Errno::INVAL), + ]; + for (digest, errno) in cases { + // Keep the guard until after the check, so it can't hide a mount. + let (status, _mounted) = mount(&image, &mnt, &["-d", digest])?; + assert_eq!(status, errno.raw_os_error(), "digest {digest}"); + assert!( + !is_mounted(&sh, &mnt), + "{} mounted with digest {digest}", + mnt.display() + ); + } + Ok(()) +} +integration_test!(privileged_capi_mount_wrong_digest); + +/// An image without fs-verity can't match an expected digest. +fn privileged_capi_mount_digest_without_verity() -> Result<()> { + if require_privileged("privileged_capi_mount_digest_without_verity")?.is_some() { + return Ok(()); + } + let sh = Shell::new()?; + let mut image = TestImage::new(&sh)?; + let mnt = image.scratch("mnt")?; + let copy = image.dir.path().join("image-copy"); + std::fs::copy(&image.image, ©)?; + image.image = copy; + + let digest = image.digest.clone(); + let (status, _mounted) = mount(&image, &mnt, &["-d", &digest])?; + assert_eq!(status, ENOVERITY.raw_os_error()); + assert!(!is_mounted(&sh, &mnt)); + Ok(()) +} +integration_test!(privileged_capi_mount_digest_without_verity); + +/// upperdir/workdir give a writable mount unless READONLY is set, as in C. +fn privileged_capi_mount_upperdir() -> Result<()> { + if require_privileged("privileged_capi_mount_upperdir")?.is_some() { + return Ok(()); + } + let sh = Shell::new()?; + let image = TestImage::new(&sh)?; + let mnt = image.scratch("mnt")?; + let upper = image.scratch("upper")?; + let work = image.scratch("work")?; + let (upper_arg, work_arg) = ( + upper.to_str().context("non-UTF-8 path")?, + work.to_str().context("non-UTF-8 path")?, + ); + let overlay_args = ["-u", upper_arg, "-w", work_arg]; + + let (status, mounted) = mount(&image, &mnt, &overlay_args)?; + assert_eq!(status, 0); + assert_content(&mnt)?; + std::fs::write(mnt.join("new"), "written\n")?; + drop(mounted); + assert_eq!(std::fs::read_to_string(upper.join("new"))?, "written\n"); + + let (status, mounted) = mount(&image, &mnt, &[&overlay_args[..], &["-r"]].concat())?; + assert_eq!(status, 0); + assert_content(&mnt)?; + let err = std::fs::write(mnt.join("readonly"), "x").expect_err("READONLY mount is writable"); + assert_eq!(err.raw_os_error(), Some(Errno::ROFS.raw_os_error())); + drop(mounted); + + // Only one of them is invalid. + let (status, _mounted) = mount(&image, &mnt, &["-u", upper_arg])?; + assert_eq!(status, Errno::INVAL.raw_os_error()); + assert!(!is_mounted(&sh, &mnt)); + Ok(()) +} +integration_test!(privileged_capi_mount_upperdir); diff --git a/crates/composefs-integration-tests/src/tests/mod.rs b/crates/composefs-integration-tests/src/tests/mod.rs index 5588a279..9a7a0b50 100644 --- a/crates/composefs-integration-tests/src/tests/mod.rs +++ b/crates/composefs-integration-tests/src/tests/mod.rs @@ -1,5 +1,6 @@ //! Integration test modules, organized by execution environment. +pub mod capi; pub mod cli; pub mod copy_image; pub mod cstor; diff --git a/crates/composefs-integration-tests/src/tests/privileged.rs b/crates/composefs-integration-tests/src/tests/privileged.rs index 7b80e8c2..7df711df 100644 --- a/crates/composefs-integration-tests/src/tests/privileged.rs +++ b/crates/composefs-integration-tests/src/tests/privileged.rs @@ -126,13 +126,13 @@ pub fn require_userns(test_name: &str) -> Result> { /// (i.e. running cfsctl without `--insecure`) must use a real filesystem. /// This creates a sparse file, formats it as ext4 with the verity feature, /// and loop-mounts it to a temp directory. -struct VerityTempDir { +pub(crate) struct VerityTempDir { mountpoint: PathBuf, _backing: tempfile::TempDir, } impl VerityTempDir { - fn new() -> Result { + pub(crate) fn new() -> Result { let backing = tempfile::tempdir()?; let img = backing.path().join("fs.img"); let mountpoint = backing.path().join("mnt"); @@ -152,7 +152,7 @@ impl VerityTempDir { }) } - fn path(&self) -> &Path { + pub(crate) fn path(&self) -> &Path { &self.mountpoint } }