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/Cargo.toml b/crates/composefs-capi/Cargo.toml index 68a2da3f..722ab703 100644 --- a/crates/composefs-capi/Cargo.toml +++ b/crates/composefs-capi/Cargo.toml @@ -9,8 +9,16 @@ 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 } +hex = { version = "0.4.0", default-features = false, features = ["std"] } libc = "0.2" anyhow = "1" rustix = { version = "1", features = ["fs", "mm", "process", "mount"] } diff --git a/crates/composefs-capi/src/mount.rs b/crates/composefs-capi/src/mount.rs index 3309ce7e..b9de0bd1 100644 --- a/crates/composefs-capi/src/mount.rs +++ b/crates/composefs-capi/src/mount.rs @@ -1,11 +1,15 @@ 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}; use crate::errno::set_errno; +/// `struct lcfs_mount_options_s` from lcfs-mount.h. +/// +/// The entry points take it as `Option<&LcfsMountOptions>`: Rust guarantees +/// that has the ABI of a nullable C pointer, with null as `None`. #[repr(C)] pub struct LcfsMountOptions { pub objdirs: *const *const c_char, @@ -21,8 +25,88 @@ 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; + +/// `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; + +/// Checks the options like the C library does before mounting, returning +/// the parsed expected image digest, if any, or an errno. +/// +/// The digest must be 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. +/// +/// # Safety +/// +/// `expected_fsverity_digest` must be null or a valid C string. +unsafe fn validate_options(options: Option<&LcfsMountOptions>) -> Result>, c_int> { + let Some(opts) = options 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); + } + // SAFETY: non-null, and the caller guarantees it's a C string. + let digest_hex = unsafe { CStr::from_ptr(opts.expected_fsverity_digest) }; + match hex::decode(digest_hex.to_bytes()) { + Ok(digest) if digest.len() <= MAX_DIGEST_SIZE => Ok(Some(digest)), + _ => Err(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) @@ -32,13 +116,19 @@ fn io_error_to_errno(e: &std::io::Error) -> c_int { pub unsafe extern "C" fn lcfs_mount_image( path: *const c_char, mountpoint: *const c_char, - options: *mut LcfsMountOptions, + options: Option<&LcfsMountOptions>, ) -> c_int { if path.is_null() || mountpoint.is_null() { set_errno(libc::EINVAL); 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); @@ -61,13 +151,21 @@ pub unsafe extern "C" fn lcfs_mount_image( pub unsafe extern "C" fn lcfs_mount_fd( fd: c_int, mountpoint: *const c_char, - options: *mut LcfsMountOptions, + options: Option<&LcfsMountOptions>, ) -> c_int { if fd < 0 || mountpoint.is_null() { set_errno(libc::EINVAL); 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,36 +175,33 @@ 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; - } - }; + // 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; - if !opts.objdirs.is_null() && opts.n_objdirs > 0 { - for i in 0..opts.n_objdirs { - let dir_ptr = *opts.objdirs.add(i); - if !dir_ptr.is_null() { - basedirs.push(CStr::from_ptr(dir_ptr).to_owned()); - } + if let Some(opts) = options + && !opts.objdirs.is_null() + && opts.n_objdirs > 0 + { + for i in 0..opts.n_objdirs { + let dir_ptr = *opts.objdirs.add(i); + if !dir_ptr.is_null() { + basedirs.push(CStr::from_ptr(dir_ptr).to_owned()); } } } - let verity = if !options.is_null() { - let opts = &*options; - if (opts.flags & LCFS_MOUNT_FLAGS_REQUIRE_VERITY) != 0 { - composefs::mount::VerityRequirement::Required - } else if (opts.flags & LCFS_MOUNT_FLAGS_TRY_VERITY) != 0 { - composefs::mount::VerityRequirement::Try - } else { - composefs::mount::VerityRequirement::Disabled - } + let flags = options.map_or(0, |opts| opts.flags); + let verity = if (flags & LCFS_MOUNT_FLAGS_REQUIRE_VERITY) != 0 { + composefs::mount::VerityRequirement::Required + } else if (flags & LCFS_MOUNT_FLAGS_TRY_VERITY) != 0 { + composefs::mount::VerityRequirement::Try } else { composefs::mount::VerityRequirement::Disabled }; @@ -114,14 +209,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; } } @@ -130,8 +221,24 @@ pub unsafe extern "C" fn lcfs_mount_fd( let borrowed: Vec<_> = basedir_fds.iter().map(|fd| fd.as_fd()).collect(); let mut mount_options = composefs::mount::MountOptions::default(); - if !options.is_null() { - let opts = &*options; + if let Some(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 { @@ -141,8 +248,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 +268,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; 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 } }