From 1c716d0d6e26f57cb9416ec7211901e00f400f80 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:52:56 +0200 Subject: [PATCH 01/11] tests: improve propagated error messages We made pretty judicious use of ?, which leads to problems if you have an error somewhere deep as there is little information at the error source about what was being attempted. The goal here is to just improve the overall amount of information we are providing when an error happens deep within some helper. This is really dumb grunt work so I used an LLM for parts of it. Signed-off-by: Aleksa Sarai --- src/tests/capi/test_compat.rs | 96 ++++++++------ src/tests/common/handle.rs | 25 ++-- src/tests/common/mntns.rs | 16 ++- src/tests/common/root.rs | 2 +- src/tests/test_race_resolve_partial.rs | 16 ++- src/tests/test_resolve.rs | 37 +++--- src/tests/test_resolve_partial.rs | 15 ++- src/tests/test_root_ops.rs | 169 ++++++++++++++++++------- 8 files changed, 249 insertions(+), 127 deletions(-) diff --git a/src/tests/capi/test_compat.rs b/src/tests/capi/test_compat.rs index dfdfa12f..7620e2a0 100644 --- a/src/tests/capi/test_compat.rs +++ b/src/tests/capi/test_compat.rs @@ -53,12 +53,13 @@ use pretty_assertions::{assert_eq, assert_matches}; #[test] fn reopen_v1() -> Result<(), Error> { - let file: OwnedFd = File::open(".")?.into(); + let file: OwnedFd = File::open(".").context("open dummy file")?.into(); let oflags = OpenFlags::O_DIRECTORY | OpenFlags::O_RDONLY | OpenFlags::O_NOATIME; let reopened_fd = capi_utils::call_capi_fd(|| { capi::core::__pathrs_reopen_v1(file.as_fd().into(), oflags.bits() as i32) - })?; + }) + .with_context(|| format!("__pathrs_reopen_v1({oflags:?})"))?; assert_ne!( file.as_raw_fd(), @@ -66,19 +67,22 @@ fn reopen_v1() -> Result<(), Error> { "new and reopened fds should have different fd numbers" ); assert_eq!( - file.as_unsafe_path_unchecked()?, - reopened_fd.as_unsafe_path_unchecked()?, + file.as_unsafe_path_unchecked() + .expect("get real path of original fd"), + reopened_fd + .as_unsafe_path_unchecked() + .expect("get real path of reopened fd"), "new and reopened fds should have the same 'real' path", ); - tests_common::check_oflags(&reopened_fd, oflags)?; + tests_common::check_oflags(&reopened_fd, oflags).expect("check reopened fd flags"); Ok(()) } #[test] fn inroot_open_v1() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(root_dir.path()).context("Root::open basic tree")?; { let path = capi_utils::path_to_cstring("b/c"); @@ -90,8 +94,10 @@ fn inroot_open_v1() -> Result<(), Error> { path.as_ptr(), oflags.bits() as _, ) - })?; - tests_common::check_oflags(&file, oflags)?; + }) + .with_context(|| format!("__pathrs_inroot_open_v1({path:?}, {oflags:?})"))?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check {path:?} {oflags:?} oflags"))?; } { @@ -104,8 +110,10 @@ fn inroot_open_v1() -> Result<(), Error> { path.as_ptr(), oflags.bits() as _, ) - })?; - tests_common::check_oflags(&file, oflags)?; + }) + .with_context(|| format!("__pathrs_inroot_open_v1({path:?}, {oflags:?})"))?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check {path:?} {oflags:?} oflags"))?; } { @@ -118,8 +126,10 @@ fn inroot_open_v1() -> Result<(), Error> { path.as_ptr(), oflags.bits() as _, ) - })?; - tests_common::check_oflags(&file, oflags)?; + }) + .with_context(|| format!("__pathrs_inroot_open_v1({path:?}, {oflags:?})"))?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check {path:?} {oflags:?} oflags"))?; } Ok(()) @@ -127,8 +137,8 @@ fn inroot_open_v1() -> Result<(), Error> { #[test] fn inroot_creat_v1() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(root_dir.path()).context("Root::open basic tree")?; { let path = capi_utils::path_to_cstring("b/c/new-file"); @@ -142,9 +152,12 @@ fn inroot_creat_v1() -> Result<(), Error> { oflags.bits() as _, mode, ) - })?; - tests_common::check_oflags(&file, oflags | OpenFlags::O_CREAT)?; - tests_common::check_mode(&file, libc::S_IFREG | mode)?; + }) + .with_context(|| format!("__pathrs_inroot_creat_v1({path:?}, {oflags:?}, 0o{mode:o})"))?; + tests_common::check_mode(&file, libc::S_IFREG | mode) + .with_context(|| format!("check created {path:?} file mode 0o{mode:o}"))?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check created {path:?} {oflags:?} oflags"))?; } Ok(()) @@ -152,8 +165,8 @@ fn inroot_creat_v1() -> Result<(), Error> { #[test] fn hardlink_v1() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(root_dir.path()).context("Root::open basic tree")?; let path1 = capi_utils::path_to_cstring("abc"); let path2 = capi_utils::path_to_cstring("b/c/file"); @@ -172,14 +185,15 @@ fn hardlink_v1() -> Result<(), Error> { ); let old_meta = root - .resolve_nofollow("b/c/file")? + .resolve_nofollow("b/c/file") + .expect("resolve b/c/file") .metadata() - .context("fstat b/c/file")?; + .expect("fstat b/c/file"); let new_meta = root .resolve_nofollow("abc") - .context("hardlink abc should've been created")? + .expect("hardlink abc should've been created") .metadata() - .context("fstat hardlink abc")?; + .expect("fstat hardlink abc"); assert_eq!( old_meta.ino(), new_meta.ino(), @@ -191,9 +205,9 @@ fn hardlink_v1() -> Result<(), Error> { #[test] fn hardlink_v2() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root1 = Root::open(root_dir.path())?; - let root2 = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root1 = Root::open(root_dir.path()).context("Root::open basic tree (#1)")?; + let root2 = Root::open(root_dir.path()).context("Root::open basic tree (#2)")?; let path1 = capi_utils::path_to_cstring("abc"); let path2 = capi_utils::path_to_cstring("b/c/file"); @@ -234,8 +248,8 @@ fn hardlink_v2() -> Result<(), Error> { #[test] fn symlink_v1() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(root_dir.path()).context("Root::open basic tree")?; let path1 = capi_utils::path_to_cstring("abc"); let path2 = capi_utils::path_to_cstring("b/c/file"); @@ -264,8 +278,8 @@ fn symlink_v1() -> Result<(), Error> { #[test] fn rename_v1() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(root_dir.path()).context("Root::open basic tree")?; let path1 = capi_utils::path_to_cstring("b/c/file"); let path2 = capi_utils::path_to_cstring("abc"); @@ -301,9 +315,9 @@ fn rename_v1() -> Result<(), Error> { #[test] fn rename_v2() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root1 = Root::open(root_dir.path())?; - let root2 = Root::open(root_dir.path())?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root1 = Root::open(root_dir.path()).context("Root::open basic tree (#1)")?; + let root2 = Root::open(root_dir.path()).context("Root::open basic tree (#2)")?; let path1 = capi_utils::path_to_cstring("b/c/file"); let path2 = capi_utils::path_to_cstring("abc"); @@ -337,15 +351,19 @@ fn procfs_open_v1() -> Result<(), Error> { path.as_ptr(), oflags.bits() as _, ) + }) + .with_context(|| { + format!("__pathrs_proc_open_v1(PATHRS_PROC_THREAD_SELF, {path:?}, {oflags:?})") })?; - tests_common::check_oflags(&file, oflags)?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check procfs {path:?} {oflags:?} oflags"))?; Ok(()) } #[test] fn procfs_openat_v1() -> Result<(), Error> { - let proc_rootfd = ProcfsHandle::new()?; + let proc_rootfd = ProcfsHandle::new().context("ProcfsHandle::new")?; let path = capi_utils::path_to_cstring("stat"); let oflags = OpenFlags::O_RDONLY | OpenFlags::O_NOFOLLOW; // SAFETY: Called with valid C-like arguments. @@ -356,8 +374,12 @@ fn procfs_openat_v1() -> Result<(), Error> { path.as_ptr(), oflags.bits() as _, ) + }) + .with_context(|| { + format!("__pathrs_proc_openat_v1(PATHRS_PROC_THREAD_SELF, {path:?}, {oflags:?})") })?; - tests_common::check_oflags(&file, oflags)?; + tests_common::check_oflags(&file, oflags) + .with_context(|| format!("check procfs {path:?} {oflags:?} oflags"))?; Ok(()) } diff --git a/src/tests/common/handle.rs b/src/tests/common/handle.rs index 356c9c21..77f7d4af 100644 --- a/src/tests/common/handle.rs +++ b/src/tests/common/handle.rs @@ -112,7 +112,7 @@ pub(in crate::tests) fn check_mode(fd: impl AsFd, create_mode: u32) -> Result<() umask.bits() }; - let got_mode = fd.metadata()?.mode() + let got_mode = fd.metadata().context("fstat fd to check mode")?.mode() // Strip type bits from mode if the caller didn't include them. & !if create_mode & libc::S_IFMT == 0 { libc::S_IFMT @@ -124,7 +124,7 @@ pub(in crate::tests) fn check_mode(fd: impl AsFd, create_mode: u32) -> Result<() create_mode & !umask, got_mode, "created fd {:?} should have mode {} ({create_mode} &^ {umask})", - fd.as_unsafe_path_unchecked()?, + fd.as_unsafe_path_unchecked().expect("get real path of fd"), create_mode & !umask ); @@ -194,7 +194,9 @@ pub(in crate::tests) fn check_reopen( (Ok(f), Ok(_)) => f, (result, expected) => { let result = match result { - Ok(file) => Ok(file.as_unsafe_path_unchecked()?), + Ok(file) => Ok(file + .as_unsafe_path_unchecked() + .context("get real path of reopened file")?), Err(err) => Err(err), }; @@ -209,16 +211,22 @@ pub(in crate::tests) fn check_reopen( } }; - let real_handle_path = handle.as_unsafe_path_unchecked()?; - let real_reopen_path = file.as_unsafe_path_unchecked()?; + let real_handle_path = handle + .as_unsafe_path_unchecked() + .context("get real path of handle")?; + let real_reopen_path = file + .as_unsafe_path_unchecked() + .context("get real path of reopened file")?; assert_eq!( real_handle_path, real_reopen_path, "reopened handle should be equivalent to old handle", ); - let clone_handle = handle.try_clone()?; - let clone_handle_path = clone_handle.as_unsafe_path_unchecked()?; + let clone_handle = handle.try_clone().context("clone handle")?; + let clone_handle_path = clone_handle + .as_unsafe_path_unchecked() + .context("get real path of cloned handle")?; assert_eq!( real_handle_path, clone_handle_path, @@ -229,7 +237,8 @@ pub(in crate::tests) fn check_reopen( &file, // NOTE: Handle::reopen() drops O_NOFOLLOW, so we shouldn't see it. flags.difference(OpenFlags::O_NOFOLLOW), - )?; + ) + .context("check reopened file flags")?; Ok(()) } diff --git a/src/tests/common/mntns.rs b/src/tests/common/mntns.rs index 5e1a1d25..380ef41b 100644 --- a/src/tests/common/mntns.rs +++ b/src/tests/common/mntns.rs @@ -78,7 +78,8 @@ pub(in crate::tests) fn mount(dst: impl AsRef, ty: MountType) -> Result<() dst, OpenFlags::O_NOFOLLOW | OpenFlags::O_PATH, 0, - )?; + ) + .with_context(|| format!("open mount destination {dst:?}"))?; let dst_path = format!("/proc/self/fd/{}", dst_file.as_raw_fd()); match ty { @@ -94,10 +95,11 @@ pub(in crate::tests) fn mount(dst: impl AsRef, ty: MountType) -> Result<() MountType::Bind { src } => { let src_file = syscalls::openat( syscalls::AT_FDCWD, - src, + &src, OpenFlags::O_NOFOLLOW | OpenFlags::O_PATH, 0, - )?; + ) + .with_context(|| format!("open bind-mount source {src:?}"))?; let src_path = format!("/proc/self/fd/{}", src_file.as_raw_fd()); rustix_mount::mount_bind(&src_path, &dst_path).with_context(|| { format!( @@ -135,7 +137,8 @@ pub(in crate::tests) fn mount(dst: impl AsRef, ty: MountType) -> Result<() dst, OpenFlags::O_NOFOLLOW | OpenFlags::O_PATH, 0, - )?; + ) + .with_context(|| format!("re-open mount destination {dst:?} for remount"))?; let dst_path = format!("/proc/self/fd/{}", dst_file.as_raw_fd()); // Then apply our mount flags. @@ -157,7 +160,7 @@ pub(in crate::tests) fn in_mnt_ns(func: F) -> Result where F: FnOnce() -> Result, { - let old_ns = File::open("/proc/self/ns/mnt")?; + let old_ns = File::open("/proc/self/ns/mnt").context("open current mount namespace")?; // TODO: Run this in a subprocess. @@ -171,7 +174,8 @@ where rustix_mount::mount_change( "/", MountPropagationFlags::DOWNSTREAM | MountPropagationFlags::REC, - )?; + ) + .context("mark / as MS_SLAVE")?; let ret = func(); diff --git a/src/tests/common/root.rs b/src/tests/common/root.rs index c7c70471..9157a53c 100644 --- a/src/tests/common/root.rs +++ b/src/tests/common/root.rs @@ -149,7 +149,7 @@ macro_rules! create_tree { // } ($($subpath:expr => $(#[$meta:meta])* ($($inner:tt)*));+ $(;)*) => { { - let root = TempDir::new()?; + let root = TempDir::new().context("create temporary dir for test tree")?; $( $(#[$meta])* { diff --git a/src/tests/test_race_resolve_partial.rs b/src/tests/test_race_resolve_partial.rs index fd0b69f8..50473dd4 100644 --- a/src/tests/test_race_resolve_partial.rs +++ b/src/tests/test_race_resolve_partial.rs @@ -41,7 +41,7 @@ use crate::{ use std::{os::unix::io::AsFd, sync::mpsc, thread}; -use anyhow::Error; +use anyhow::{Context, Error}; macro_rules! resolve_race_tests { // resolve_race_tests! { @@ -54,7 +54,7 @@ macro_rules! resolve_race_tests { #[cfg_attr(not(feature = "_test_race"), ignore)] fn []() -> Result<(), Error> { let (tmpdir, root_dir) = $root_dir; - let mut $root_var = Root::open(&root_dir)?; + let mut $root_var = Root::open(&root_dir).context("Root::open")?; assert_eq!( $root_var.resolver_backend(), ResolverBackend::default(), @@ -74,7 +74,7 @@ macro_rules! resolve_race_tests { #[cfg_attr(not(feature = "_test_race"), ignore)] fn []() -> Result<(), Error> { let (tmpdir, root_dir) = $root_dir; - let mut $root_var = Root::open(&root_dir)?; + let mut $root_var = Root::open(&root_dir).context("Root::open")?; $root_var.set_resolver_backend(ResolverBackend::KernelOpenat2); assert_eq!( $root_var.resolver_backend(), @@ -107,7 +107,7 @@ macro_rules! resolve_race_tests { } let (tmpdir, root_dir) = $root_dir; - let mut $root_var = Root::open(&root_dir)?; + let mut $root_var = Root::open(&root_dir).context("Root::open")?; $root_var.set_resolver_backend(ResolverBackend::EmulatedOpath); assert_eq!( $root_var.resolver_backend(), @@ -133,7 +133,7 @@ macro_rules! resolve_race_tests { (@impl [$rename_a:literal <=> $rename_b:literal] $test_name:ident $op_name:ident ($path:expr, $rflags:expr, $no_follow_trailing:expr) => { $($expected:tt)* }) => { paste::paste! { resolve_race_tests! { - [tests_common::create_race_tree()?] + [tests_common::create_race_tree().context("create race tree")?] fn [<$op_name _ $test_name>](mut root: Root) { root.set_resolver_flags($rflags); @@ -822,7 +822,7 @@ mod utils { sync::mpsc::{Receiver, RecvError, SyncSender, TryRecvError}, }; - use anyhow::Error; + use anyhow::{Context, Error}; use path_clean::PathClean; pub(super) enum RenameStateMsg { @@ -904,7 +904,9 @@ mod utils { .send(RenameStateMsg::Pause) .expect("should be able to pause rename attack"); - let root_dir = root.as_unsafe_path_unchecked()?; + let root_dir = root + .as_unsafe_path_unchecked() + .context("get real path of root")?; // Convert the handle to something useful for our tests. let result = result.map(|lookup_result| { diff --git a/src/tests/test_resolve.rs b/src/tests/test_resolve.rs index b00b4f95..7da44a7d 100644 --- a/src/tests/test_resolve.rs +++ b/src/tests/test_resolve.rs @@ -36,7 +36,7 @@ use crate::{error::ErrorKind, flags::ResolverFlags, resolvers::ResolverBackend, use std::path::Path; -use anyhow::Error; +use anyhow::{Context, Error}; macro_rules! resolve_tests { // resolve_tests! { @@ -51,7 +51,7 @@ macro_rules! resolve_tests { $(#[cfg_attr(not($ignore_meta), ignore)])* fn []() -> Result<(), Error> { utils::$with_root_fn(|root_dir: &Path| { - let mut $root_var = Root::open(root_dir)?; + let mut $root_var = Root::open(root_dir).context("Root::open")?; assert_eq!( $root_var.resolver_backend(), ResolverBackend::default(), @@ -70,7 +70,7 @@ macro_rules! resolve_tests { $(#[cfg_attr(not($ignore_meta), ignore)])* fn []() -> Result<(), Error> { utils::$with_root_fn(|root_dir: &Path| { - let root = Root::open(root_dir)?; + let root = Root::open(root_dir).context("Root::open")?; let mut $root_var = root.as_ref(); assert_eq!( $root_var.resolver_backend(), @@ -90,7 +90,7 @@ macro_rules! resolve_tests { $(#[cfg_attr(not($ignore_meta), ignore)])* fn []() -> Result<(), Error> { utils::$with_root_fn(|root_dir: &Path| { - let mut $root_var = Root::open(root_dir)?; + let mut $root_var = Root::open(root_dir).context("Root::open")?; $root_var.set_resolver_backend(ResolverBackend::KernelOpenat2); assert_eq!( $root_var.resolver_backend(), @@ -113,7 +113,7 @@ macro_rules! resolve_tests { $(#[cfg_attr(not($ignore_meta), ignore)])* fn []() -> Result<(), Error> { utils::$with_root_fn(|root_dir: &Path| { - let root = Root::open(root_dir)?; + let root = Root::open(root_dir).context("Root::open")?; let mut $root_var = root.as_ref(); $root_var.set_resolver_backend(ResolverBackend::KernelOpenat2); assert_eq!( @@ -145,7 +145,7 @@ macro_rules! resolve_tests { } utils::$with_root_fn(|root_dir: &Path| { - let mut $root_var = Root::open(root_dir)?; + let mut $root_var = Root::open(root_dir).context("Root::open")?; $root_var.set_resolver_backend(ResolverBackend::EmulatedOpath); assert_eq!( $root_var.resolver_backend(), @@ -177,7 +177,7 @@ macro_rules! resolve_tests { } utils::$with_root_fn(|root_dir: &Path| { - let root = Root::open(root_dir)?; + let root = Root::open(root_dir).context("Root::open")?; let mut $root_var = root .as_ref(); $root_var.set_resolver_backend(ResolverBackend::EmulatedOpath); @@ -209,7 +209,7 @@ macro_rules! resolve_tests { $(#[cfg_attr(not($ignore_meta), ignore)])* fn []() -> Result<(), Error> { utils::$with_root_fn(|root_dir: &Path| { - let $root_var = CapiRoot::open(root_dir)?; + let $root_var = CapiRoot::open(root_dir).context("CapiRoot::open")?; { $body } @@ -548,7 +548,7 @@ mod utils { where F: FnOnce(&Path) -> Result<(), Error>, { - let root_dir = tests_common::create_basic_tree()?; + let root_dir = tests_common::create_basic_tree().context("create_basic_tree")?; let res = func(root_dir.path()); @@ -568,9 +568,10 @@ mod utils { F: FnOnce(&Path) -> Result<(), Error>, { tests_common::in_mnt_ns(|| { - let root_dir = tests_common::create_basic_tree()?; + let root_dir = tests_common::create_basic_tree().context("create_basic_tree")?; - tests_common::mask_nosymfollow(root_dir.path())?; + tests_common::mask_nosymfollow(root_dir.path()) + .with_context(|| format!("could not mask {root_dir:?} with MS_NOSYMFOLLOW"))?; let res = func(root_dir.path()); @@ -590,7 +591,9 @@ mod utils { R: RootImpl, for<'a> &'a R::Handle: HandleImpl, { - let root_dir = root.as_unsafe_path_unchecked()?; + let root_dir = root + .as_unsafe_path_unchecked() + .context("get real path of root")?; let unsafe_path = unsafe_path.as_ref(); let result = if no_follow_trailing { @@ -607,7 +610,9 @@ mod utils { ), (result, expected) => { let result = match result { - Ok(handle) => Ok(handle.as_unsafe_path_unchecked()?), + Ok(handle) => Ok(handle + .as_unsafe_path_unchecked() + .context("get real path of resolved handle")?), Err(err) => Err(err), }; @@ -623,14 +628,16 @@ mod utils { }; let expected_path = expected_path.trim_start_matches('/'); - let real_handle_path = handle.as_unsafe_path_unchecked()?; + let real_handle_path = handle + .as_unsafe_path_unchecked() + .context("get real path of resolved handle")?; assert_eq!( real_handle_path, root_dir.join(expected_path), "resolve({unsafe_path:?}, {no_follow_trailing}) path mismatch", ); - let meta = handle.metadata()?; + let meta = handle.metadata().context("fstat resolved handle")?; let real_file_type = meta.mode() & libc::S_IFMT; assert_eq!(real_file_type, expected_file_type, "file type mismatch",); diff --git a/src/tests/test_resolve_partial.rs b/src/tests/test_resolve_partial.rs index 65e07ed0..a13b7111 100644 --- a/src/tests/test_resolve_partial.rs +++ b/src/tests/test_resolve_partial.rs @@ -740,8 +740,8 @@ mod utils { where F: FnOnce(Root) -> Result<(), Error>, { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create_basic_tree")?; + let root = Root::open(&root_dir).context("Root::open")?; let res = func(root); @@ -754,10 +754,11 @@ mod utils { F: FnOnce(Root) -> Result<(), Error>, { tests_common::in_mnt_ns(|| { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create_basic_tree")?; + let root = Root::open(&root_dir).context("Root::open")?; - tests_common::mask_nosymfollow(root_dir.path())?; + tests_common::mask_nosymfollow(root_dir.path()) + .with_context(|| format!("could not mask {root_dir:?} with MS_NOSYMFOLLOW"))?; let res = func(root); @@ -772,7 +773,9 @@ mod utils { no_follow_trailing: bool, expected: Result, ErrorKind>, ) -> Result<(), Error> { - let root_dir = root.as_unsafe_path_unchecked()?; + let root_dir = root + .as_unsafe_path_unchecked() + .context("get real path of root")?; let unsafe_path = unsafe_path.as_ref(); let result = root diff --git a/src/tests/test_root_ops.rs b/src/tests/test_root_ops.rs index c494da14..48226bfb 100644 --- a/src/tests/test_root_ops.rs +++ b/src/tests/test_root_ops.rs @@ -43,7 +43,7 @@ use crate::{ use std::{fs::Permissions, os::unix::fs::PermissionsExt}; -use anyhow::Error; +use anyhow::{Context, Error}; macro_rules! root_op_tests { ($(#[$meta:meta])* fn $test_name:ident ($root_var:ident) $body:block) => { @@ -51,8 +51,8 @@ macro_rules! root_op_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let $root_var = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let $root_var = Root::open(&root_dir).context("Root::open basic tree")?; $body } @@ -60,8 +60,8 @@ macro_rules! root_op_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(&root_dir).context("Root::open basic tree")?; let $root_var = root.as_ref(); $body @@ -70,8 +70,9 @@ macro_rules! root_op_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let $root_var = Root::open(&root_dir)? + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let $root_var = Root::open(&root_dir) + .context("Root::open basic tree")? .with_resolver_backend(ResolverBackend::KernelOpenat2); if !$root_var.resolver_backend().supported() { // Skip if not supported. @@ -84,8 +85,8 @@ macro_rules! root_op_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(&root_dir).context("Root::open basic tree")?; let $root_var = root .as_ref() .with_resolver_backend(ResolverBackend::KernelOpenat2); @@ -109,8 +110,9 @@ macro_rules! root_op_tests { return Ok(()); } - let root_dir = tests_common::create_basic_tree()?; - let $root_var = Root::open(&root_dir)? + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let $root_var = Root::open(&root_dir) + .context("Root::open basic tree")? .with_resolver_backend(ResolverBackend::EmulatedOpath); // EmulatedOpath is always supported. assert!( @@ -133,8 +135,8 @@ macro_rules! root_op_tests { return Ok(()); } - let root_dir = tests_common::create_basic_tree()?; - let root = Root::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let root = Root::open(&root_dir).context("Root::open basic tree")?; let $root_var = root .as_ref() .with_resolver_backend(ResolverBackend::EmulatedOpath); @@ -151,8 +153,8 @@ macro_rules! root_op_tests { #[cfg(feature = "capi")] $(#[$meta])* fn []() -> Result<(), Error> { - let root_dir = tests_common::create_basic_tree()?; - let $root_var = capi::CapiRoot::open(&root_dir)?; + let root_dir = tests_common::create_basic_tree().context("create basic tree")?; + let $root_var = capi::CapiRoot::open(&root_dir).context("CapiRoot::open basic tree")?; $body } @@ -920,7 +922,7 @@ mod utils { }; fn root_roundtrip(root: R) -> Result { - let root_clone = root.try_clone()?; + let root_clone = root.try_clone().context("clone root")?; assert_eq!( root.resolver(), root_clone.resolver(), @@ -943,7 +945,10 @@ mod utils { let _ = rustix_process::umask(Mode::empty()); // Update the expected path to have the rootdir as a prefix. - let root_dir = root.as_fd().as_unsafe_path_unchecked()?; + let root_dir = root + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of root")?; let expected_result = expected_result.map(|(path, mode)| (root_dir.join(path), mode)); match root.create(path, &inode_type) { @@ -953,10 +958,15 @@ mod utils { } Ok(_) => { let root = root_roundtrip(root)?; - let created = root.resolve_nofollow(path)?; - let meta = created.metadata()?; + let created = root + .resolve_nofollow(path) + .with_context(|| format!("resolve created {path:?}"))?; + let meta = created.metadata().context("fstat created inode")?; - let actual_path = created.as_fd().as_unsafe_path_unchecked()?; + let actual_path = created + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of created inode")?; let actual_mode = meta.mode(); assert_eq!( Ok((actual_path.clone(), actual_mode)), @@ -973,7 +983,12 @@ mod utils { } // Check hardlink is the same inode. InodeType::Hardlink(target) => { - let target_meta = root.resolve_nofollow(target)?.as_fd().metadata()?; + let target_meta = root + .resolve_nofollow(&target) + .with_context(|| format!("resolve hardlink target {target:?}"))? + .as_fd() + .metadata() + .context("fstat hardlink target")?; assert_eq!( meta.ino(), target_meta.ino(), @@ -983,13 +998,16 @@ mod utils { // Check symlink is correct. InodeType::Symlink(target) => { // Check using the a resolved handle. - let actual_target = syscalls::readlinkat(&created, "")?; + let actual_target = syscalls::readlinkat(&created, "") + .context("readlink created symlink")?; assert_eq!( target, actual_target, "readlinkat(handle) link target mismatch" ); // Double-check with Root::readlink. - let actual_target = root.readlink(path)?; + let actual_target = root + .readlink(path) + .with_context(|| format!("root readlink {path:?}"))?; assert_eq!( target, actual_target, "root.readlink(path) link target mismatch" @@ -1017,7 +1035,10 @@ mod utils { let pre_create_handle = root.resolve_nofollow(path); // do not unwrap // Update the expected path to have the rootdir as a prefix. - let root_dir = root.as_fd().as_unsafe_path_unchecked()?; + let root_dir = root + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of root")?; let expected_result = expected_result.map(|path| root_dir.join(path)); match root.create_file(path, oflags, perm) { @@ -1026,7 +1047,10 @@ mod utils { .with_context(|| format!("root create file {path:?}"))?; } Ok(file) => { - let actual_path = file.as_fd().as_unsafe_path_unchecked()?; + let actual_path = file + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of created file")?; assert_eq!( Ok(actual_path.clone()), expected_result, @@ -1039,18 +1063,27 @@ mod utils { .wrap("re-open created file using original path")?; assert_eq!( - new_lookup.as_fd().as_unsafe_path_unchecked()?, - file.as_fd().as_unsafe_path_unchecked()?, + new_lookup + .as_fd() + .as_unsafe_path_unchecked() + .expect("get real path of re-opened file"), + file.as_fd() + .as_unsafe_path_unchecked() + .expect("get real path of created file"), "expected real path of {path:?} handles to be the same", ); let expect_mode = if let Ok(handle) = pre_create_handle { - handle.as_fd().metadata()?.mode() + handle + .as_fd() + .metadata() + .context("fstat pre-existing file")? + .mode() } else { libc::S_IFREG | perm.mode() }; - let orig_meta = file.as_fd().metadata()?; + let orig_meta = file.as_fd().metadata().context("fstat created file")?; assert_eq!( orig_meta.mode(), expect_mode, @@ -1058,7 +1091,10 @@ mod utils { orig_meta.mode(), ); - let new_meta = new_lookup.as_fd().metadata()?; + let new_meta = new_lookup + .as_fd() + .metadata() + .context("fstat re-opened file")?; assert_eq!( orig_meta.ino(), new_meta.ino(), @@ -1068,7 +1104,8 @@ mod utils { // Note that create_file is always implemented as a two-step // process (open the parent, create the file) with O_NOFOLLOW // always being applied to the created handle (to avoid races). - tests_common::check_oflags(&file, oflags | OpenFlags::O_NOFOLLOW)?; + tests_common::check_oflags(&file, oflags | OpenFlags::O_NOFOLLOW) + .context("check created file flags")?; } } Ok(()) @@ -1083,7 +1120,10 @@ mod utils { let path = path.as_ref(); // Update the expected path to have the rootdir as a prefix. - let root_dir = root.as_fd().as_unsafe_path_unchecked()?; + let root_dir = root + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of root")?; let expected_result = expected_result.map(|path| root_dir.join(path)); match root.open_subpath(path, oflags) { @@ -1092,7 +1132,10 @@ mod utils { .with_context(|| format!("root open subpath {path:?}"))?; } Ok(file) => { - let actual_path = file.as_fd().as_unsafe_path_unchecked()?; + let actual_path = file + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of opened subpath")?; assert_eq!( Ok(actual_path.clone()), expected_result, @@ -1108,12 +1151,17 @@ mod utils { .wrap("re-open created file using original path")?; assert_eq!( - new_lookup.as_fd().as_unsafe_path_unchecked()?, - file.as_fd().as_unsafe_path_unchecked()?, + new_lookup + .as_fd() + .as_unsafe_path_unchecked() + .expect("get real path of re-opened file"), + file.as_fd() + .as_unsafe_path_unchecked() + .expect("get real path of opened subpath"), "expected real path of {path:?} handles to be the same", ); - tests_common::check_oflags(&file, oflags)?; + tests_common::check_oflags(&file, oflags).context("check opened subpath flags")?; } } Ok(()) @@ -1166,7 +1214,7 @@ mod utils { // It's possible that the path didn't exist for remove_all, but if // it did check that it was unlinked. if let Ok(handle) = handle { - let meta = handle.as_fd().metadata()?; + let meta = handle.as_fd().metadata().context("fstat removed inode")?; assert_eq!(meta.nlink(), 0, "deleted file should have a 0 nlink"); } @@ -1244,12 +1292,22 @@ mod utils { // Keep track of the original paths, pre-rename. let src_real_path = if let Ok(ref handle) = src_handle { - Some(handle.as_fd().as_unsafe_path_unchecked()?) + Some( + handle + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of rename source")?, + ) } else { None }; let dst_real_path = if let Ok(ref handle) = dst_handle { - Some(handle.as_fd().as_unsafe_path_unchecked()?) + Some( + handle + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of rename destination")?, + ) } else { None }; @@ -1265,7 +1323,10 @@ mod utils { let src_real_path = src_real_path.unwrap(); // Confirm that the handle was moved. - let moved_src_real_path = src_handle.as_fd().as_unsafe_path_unchecked()?; + let moved_src_real_path = src_handle + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of moved source")?; assert_ne!( src_real_path, moved_src_real_path, "expected real path of handle to move after rename" @@ -1285,7 +1346,10 @@ mod utils { ); // Confirm that the destination was also moved. - let moved_dst_real_path = dst_handle.as_fd().as_unsafe_path_unchecked()?; + let moved_dst_real_path = dst_handle + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of moved destination")?; assert_eq!( src_real_path, moved_dst_real_path, "expected real path of destination to move to source with RENAME_EXCHANGE" @@ -1298,7 +1362,10 @@ mod utils { .resolve_nofollow(src_path) .wrap("expected source to exist with RENAME_WHITEOUT")?; - let meta = new_lookup.as_fd().metadata()?; + let meta = new_lookup + .as_fd() + .metadata() + .context("fstat whiteout entry")?; assert_eq!( syscalls::devmajorminor(meta.rdev()), (0, 0), @@ -1317,7 +1384,10 @@ mod utils { let src_real_path = src_real_path.unwrap(); // Confirm the handle was not moved. - let nonmoved_src_real_path = src_handle.as_fd().as_unsafe_path_unchecked()?; + let nonmoved_src_real_path = src_handle + .as_fd() + .as_unsafe_path_unchecked() + .context("get real path of unmoved source")?; assert_eq!( src_real_path, nonmoved_src_real_path, "expected real path of handle to not change after failed rename" @@ -1337,7 +1407,10 @@ mod utils { // Before trying to create the directory tree, figure out what // components don't exist yet so we can check them later. - let before_partial_lookup = root.resolver().resolve_partial(root, unsafe_path, false)?; + let before_partial_lookup = root + .resolver() + .resolve_partial(root, unsafe_path, false) + .with_context(|| format!("resolve_partial {unsafe_path:?} before mkdir_all"))?; let expected_subdir_state: Option<((_, _), _)> = match expected_result { Err(_) => None, @@ -1350,7 +1423,7 @@ mod utils { let mut expected_mode = libc::S_IFDIR | (perm.mode() & !0o022); let handle: &Handle = before_partial_lookup.as_ref(); - let dir_meta = handle.metadata()?; + let dir_meta = handle.metadata().context("fstat partial lookup handle")?; if dir_meta.mode() & libc::S_ISGID == libc::S_ISGID { expected_gid = dir_meta.gid(); expected_mode |= libc::S_ISGID; @@ -1433,7 +1506,8 @@ mod utils { let mut limit = rustix_process::getrlimit(Resource::Nofile); limit.current = limit.maximum; limit - })?; + }) + .context("raise NOFILE rlimit")?; // Do lots of runs to try to catch any possible races. let num_retries = 100 + 1_000 / (1 + (num_threads >> 5)); @@ -1471,7 +1545,8 @@ mod utils { let mut limit = rustix_process::getrlimit(Resource::Nofile); limit.current = limit.maximum; limit - })?; + }) + .context("raise NOFILE rlimit")?; // Do lots of runs to try to catch any possible races. let num_retries = 100 + 1_000 / (1 + (num_threads >> 5)); From 0d962e2490dc28050b52347604b3e93e3a9aacb7 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:15 +0200 Subject: [PATCH 02/11] tests: capi: use O_NOCTTY rather than O_NOATIME O_NOATIME requires privileges that we might not have and appears to be problematic in containers. O_NOCTTY is a better "dummy" flag to use. Signed-off-by: Aleksa Sarai --- src/tests/capi/test_compat.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tests/capi/test_compat.rs b/src/tests/capi/test_compat.rs index 7620e2a0..ab4e5fa2 100644 --- a/src/tests/capi/test_compat.rs +++ b/src/tests/capi/test_compat.rs @@ -55,7 +55,7 @@ use pretty_assertions::{assert_eq, assert_matches}; fn reopen_v1() -> Result<(), Error> { let file: OwnedFd = File::open(".").context("open dummy file")?.into(); - let oflags = OpenFlags::O_DIRECTORY | OpenFlags::O_RDONLY | OpenFlags::O_NOATIME; + let oflags = OpenFlags::O_DIRECTORY | OpenFlags::O_RDONLY | OpenFlags::O_NOCTTY; let reopened_fd = capi_utils::call_capi_fd(|| { capi::core::__pathrs_reopen_v1(file.as_fd().into(), oflags.bits() as i32) }) @@ -86,7 +86,7 @@ fn inroot_open_v1() -> Result<(), Error> { { let path = capi_utils::path_to_cstring("b/c"); - let oflags = OpenFlags::O_DIRECTORY | OpenFlags::O_RDONLY | OpenFlags::O_NOATIME; + let oflags = OpenFlags::O_DIRECTORY | OpenFlags::O_RDONLY | OpenFlags::O_NOCTTY; // SAFETY: Called with valid C-like arguments. let file = capi_utils::call_capi_fd(|| unsafe { capi::core::__pathrs_inroot_open_v1( @@ -142,7 +142,7 @@ fn inroot_creat_v1() -> Result<(), Error> { { let path = capi_utils::path_to_cstring("b/c/new-file"); - let oflags = OpenFlags::O_RDWR | OpenFlags::O_NOATIME | OpenFlags::O_EXCL; + let oflags = OpenFlags::O_RDWR | OpenFlags::O_NOCTTY | OpenFlags::O_EXCL; let mode = 0o644; // SAFETY: Called with valid C-like arguments. let file = capi_utils::call_capi_fd(|| unsafe { From eb678fd23c1a851d92ac7712a5b98a5002f30665 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:18 +0200 Subject: [PATCH 03/11] tests: add _test_can_mknod feature When we start running tests in containers, mknod(2) is blocked by the devices cgroup and so will fail even for root. Signed-off-by: Aleksa Sarai --- .github/workflows/rust.yml | 2 +- Cargo.toml | 1 + src/tests/test_root_ops.rs | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 19044307..28179284 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -231,7 +231,7 @@ jobs: FEATURES: >- capi _test_race - ${{ matrix.run-as == 'root' && '_test_as_root' || '' }} + ${{ matrix.run-as == 'root' && '_test_as_root _test_can_mknod' || '' }} steps: - uses: actions/checkout@v6 # Nightly rust is required for llvm-cov --doc. diff --git a/Cargo.toml b/Cargo.toml index fd80f769..54556ee3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ capi = ["dep:bytemuck", "bitflags/bytemuck", "dep:rand", "dep:open-enum"] # not be used by actual users of libpathrs! The leading "_" should mean that # they are hidden from documentation (such as the features list on crates.io). _test_as_root = [] +_test_can_mknod = [] _test_race = [] [profile.release] diff --git a/src/tests/test_root_ops.rs b/src/tests/test_root_ops.rs index 48226bfb..67215be4 100644 --- a/src/tests/test_root_ops.rs +++ b/src/tests/test_root_ops.rs @@ -502,42 +502,78 @@ root_op_tests! { root_dotdot: mkfifo("..", 0o755) => Err(ErrorKind::InvalidArgument); root_dotdot_trailing_slash: mkfifo("../", 0o755) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] plain: mkblk("abc", 0o001, 123, 456) => Ok(("abc", libc::S_IFBLK | 0o001)); + #[cfg(feature = "_test_can_mknod")] exist_file: mkblk("b/c/file", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_dir: mkblk("a", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_symlink: mkblk("b-file", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_dangling_symlink: mkblk("a-fake1", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_slash: mkblk("b/c//foobar", 0o123, 123, 456) => Ok(("b/c/foobar", libc::S_IFBLK | 0o123)); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_dot: mkblk("b/c/./foobar", 0o456, 123, 456) => Ok(("b/c/foobar", libc::S_IFBLK | 0o456)); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_dotdot: mkblk("b/c/../foobar", 0o321, 123, 456) => Ok(("b/foobar", libc::S_IFBLK | 0o321)); + #[cfg(feature = "_test_can_mknod")] trailing_slash1: mkblk("foobar/", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_slash2: mkblk("foobar///", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_dot: mkblk("foobar/.", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_dotdot: mkblk("foobar/..", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_slash: mkblk("/", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_slash2: mkblk("//", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dot: mkblk(".", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dot_trailing_slash: mkblk("./", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dotdot: mkblk("..", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dotdot_trailing_slash: mkblk("../", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] plain: mkchar("abc", 0o010, 111, 222) => Ok(("abc", libc::S_IFCHR | 0o010)); + #[cfg(feature = "_test_can_mknod")] exist_file: mkchar("b/c/file", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_dir: mkchar("a", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_symlink: mkchar("b-file", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] exist_dangling_symlink: mkchar("a-fake1", 0o444, 123, 456) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_slash: mkchar("b/c//foobar", 0o123, 123, 456) => Ok(("b/c/foobar", libc::S_IFCHR | 0o123)); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_dot: mkchar("b/c/./foobar", 0o456, 123, 456) => Ok(("b/c/foobar", libc::S_IFCHR | 0o456)); + #[cfg(feature = "_test_can_mknod")] parentdir_trailing_dotdot: mkchar("b/c/../foobar", 0o321, 123, 456) => Ok(("b/foobar", libc::S_IFCHR | 0o321)); + #[cfg(feature = "_test_can_mknod")] trailing_slash1: mkchar("foobar/", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_slash2: mkchar("foobar///", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_dot: mkchar("foobar/.", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] trailing_dotdot: mkchar("foobar/..", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_slash: mkchar("/", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_slash2: mkchar("//", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dot: mkchar(".", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dot_trailing_slash: mkchar("./", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dotdot: mkchar("..", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); + #[cfg(feature = "_test_can_mknod")] root_dotdot_trailing_slash: mkchar("../", 0o755, 123, 456) => Err(ErrorKind::InvalidArgument); plain: create_file("abc", O_RDONLY, 0o100) => Ok("abc"); @@ -785,7 +821,9 @@ root_op_tests! { noreplace_symlink: rename("a", "b-file", RenameFlags::RENAME_NOREPLACE) => Err(ErrorKind::OsError(Some(libc::EEXIST))); noreplace_dangling_symlink: rename("a", "a-fake1", RenameFlags::RENAME_NOREPLACE) => Err(ErrorKind::OsError(Some(libc::EEXIST))); noreplace_eexist: rename("a", "e", RenameFlags::RENAME_NOREPLACE) => Err(ErrorKind::OsError(Some(libc::EEXIST))); + #[cfg(feature = "_test_can_mknod")] whiteout_dir: rename("a", "aa", RenameFlags::RENAME_WHITEOUT) => Ok(()); + #[cfg(feature = "_test_can_mknod")] whiteout_file: rename("b/c/file", "b/c/newfile", RenameFlags::RENAME_WHITEOUT) => Ok(()); exchange_dir: rename("a", "b", RenameFlags::RENAME_EXCHANGE) => Ok(()); exchange_dir_trailing_slash_from: rename("a/", "b", RenameFlags::RENAME_EXCHANGE) => Ok(()); From e261ad1c3e3e9361b903e15ce210bca4c33292b3 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:33 +0200 Subject: [PATCH 04/11] hotfix: tests: allow skipping in-mntns tests When running in a container without CAP_SYS_ADMIN or with AppArmor enabled, you end up not being able to create namespaces or mount and it's not really easy to detect this at compile-time. This is just a hotfix because it fakes the test results to look like a pass, ideally we would be using the test-if crate I'm working on to let you do runtime conditional skipping. Signed-off-by: Aleksa Sarai --- src/tests/common/mntns.rs | 41 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/tests/common/mntns.rs b/src/tests/common/mntns.rs index 380ef41b..e29a2158 100644 --- a/src/tests/common/mntns.rs +++ b/src/tests/common/mntns.rs @@ -40,6 +40,7 @@ use std::{ use anyhow::{bail, Context, Error}; use rustix::{ + io::Errno, mount::{self as rustix_mount, MountFlags, MountPropagationFlags}, thread::{self as rustix_thread, LinkNameSpaceType, UnshareFlags}, }; @@ -158,6 +159,7 @@ pub(in crate::tests) fn mount(dst: impl AsRef, ty: MountType) -> Result<() pub(in crate::tests) fn in_mnt_ns(func: F) -> Result where + T: Default, F: FnOnce() -> Result, { let old_ns = File::open("/proc/self/ns/mnt").context("open current mount namespace")?; @@ -167,20 +169,39 @@ where // SAFETY: CLONE_FS | CLONE_NEWNS do not impact the IO safety of file // descriptors, and we do not send file descriptors from the test to other // threads anyway. - unsafe { rustix_thread::unshare_unsafe(UnshareFlags::FS | UnshareFlags::NEWNS) } - .expect("unable to create a mount namespace"); + match unsafe { rustix_thread::unshare_unsafe(UnshareFlags::FS | UnshareFlags::NEWNS) } { + // If we hit an EACCES or EPERM then we are either missing CAP_SYS_ADMIN + // or some LSM policy is blocking us from unsharing the namespace. + Err(Errno::ACCESS) | Err(Errno::PERM) => { + // FIXME(libtest skip): Use test-if to skip this at runtime. + eprintln!("cannot create a mount namespace"); + return Ok(T::default()); + } + res => res, + } + .context("unable to create a mount namespace")?; + + let _mntns_guard = scopeguard::guard(old_ns, |old_ns| { + rustix_thread::move_into_link_name_space(old_ns.as_fd(), Some(LinkNameSpaceType::Mount)) + .expect("unable to rejoin old namespace"); + }); // Mark / as MS_SLAVE ("DOWNSTREAM" in rustix) to avoid DoSing the host. - rustix_mount::mount_change( + match rustix_mount::mount_change( "/", MountPropagationFlags::DOWNSTREAM | MountPropagationFlags::REC, - ) + ) { + // If we hit an EACCES or EPERM then we are either missing CAP_SYS_ADMIN + // or some LSM policy (likely AppArmor for container runners) is + // blocking us from doing any mount operations. + Err(Errno::ACCESS) | Err(Errno::PERM) => { + // FIXME(libtest skip): Use test-if to skip this at runtime. + eprintln!("cannot create a mount namespace"); + return Ok(T::default()); + } + res => res, + } .context("mark / as MS_SLAVE")?; - let ret = func(); - - rustix_thread::move_into_link_name_space(old_ns.as_fd(), Some(LinkNameSpaceType::Mount)) - .expect("unable to rejoin old namespace"); - - ret + func() } From ea52dd5d82bf9082fcdb4a04cd280ee17d829dbe Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:36 +0200 Subject: [PATCH 05/11] hotfix: tests: skip procfs tests if /proc/sys is overmounted This is really not an ideal solution but for now we should just skip the overmount tests if /proc/sys is already overmounted as the tests will fail when expecting no overmounts. The same goes for the sysctl unit tests, which expect /proc/sys to not be overmounted. Signed-off-by: Aleksa Sarai --- src/tests.rs | 3 ++ src/tests/common/procfs.rs | 64 ++++++++++++++++++++++++++++++++++++++ src/tests/test_procfs.rs | 13 ++++++++ src/utils/sysctl.rs | 16 ++++++++++ 4 files changed, 96 insertions(+) create mode 100644 src/tests/common/procfs.rs diff --git a/src/tests.rs b/src/tests.rs index 9b2726c7..a3e35ec6 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -34,6 +34,9 @@ pub(crate) mod common { mod root; pub(crate) use root::*; + mod procfs; + pub(crate) use procfs::*; + mod mntns; pub(in crate::tests) use mntns::*; diff --git a/src/tests/common/procfs.rs b/src/tests/common/procfs.rs new file mode 100644 index 00000000..39756482 --- /dev/null +++ b/src/tests/common/procfs.rs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 OR LGPL-3.0-or-later +/* + * libpathrs: safe path resolution on Linux + * Copyright (C) 2026 Aleksa Sarai + * + * == MPL-2.0 == + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * Alternatively, this Source Code Form may also (at your option) be used + * under the terms of the GNU Lesser General Public License Version 3, as + * described below: + * + * == LGPL-3.0-or-later == + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +use crate::{ + syscalls, + utils::{self, RawProcfsRoot}, +}; + +// Awful hack to detect the /proc/sys overmount case in containers. +pub(crate) fn has_proc_sys_overmounts() -> bool { + let proc_root_mnt_id = + utils::fetch_mnt_id(RawProcfsRoot::UnsafeGlobal, syscalls::BADFD, "/proc") + .expect("get mount id of /proc"); + let proc_sys_mnt_id = + utils::fetch_mnt_id(RawProcfsRoot::UnsafeGlobal, syscalls::BADFD, "/proc/sys") + .expect("get mount id of /proc/sys"); + + proc_root_mnt_id != proc_sys_mnt_id +} + +// FIXME(libtest skip): Replace with this a panic-based runtime test skipping +// harness, as this hotfix only works if the top-level #[test] function calls +// this. +#[allow(clippy::unused_unit)] +macro_rules! hotfix_skip_if_proc_sys_overmounts { + (return $ret:expr) => { + if $crate::tests::common::has_proc_sys_overmounts() { + ::std::eprintln!("/proc/sys has overmounts -- skipping this test."); + return $ret; + } + }; + () => { + $crate::tests::common::hotfix_skip_if_proc_sys_overmounts!(return Ok(())); + }; +} +pub(crate) use hotfix_skip_if_proc_sys_overmounts; diff --git a/src/tests/test_procfs.rs b/src/tests/test_procfs.rs index 6f0ecbbf..3e13db97 100644 --- a/src/tests/test_procfs.rs +++ b/src/tests/test_procfs.rs @@ -38,6 +38,7 @@ use crate::{ procfs::{ProcfsBase, ProcfsHandle, ProcfsHandleBuilder}, resolvers::procfs::ProcfsResolver, syscalls, + tests::common as tests_common, }; use utils::ExpectedResult; @@ -51,6 +52,9 @@ macro_rules! procfs_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(); + utils::[]( || $procfs_inst, $($args,)* @@ -62,6 +66,9 @@ macro_rules! procfs_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(); + utils::[]( || { let mut proc = $procfs_inst ?; @@ -78,6 +85,9 @@ macro_rules! procfs_tests { #[test] $(#[$meta])* fn []() -> Result<(), Error> { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(); + // This test only makes sense if openat2 is supported (i.e., the // default resolver is openat2 -- otherwise the default test // already tested this case). @@ -109,6 +119,9 @@ macro_rules! procfs_tests { #[cfg(feature = "capi")] $(#[$meta])* fn []() -> Result<(), Error> { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(); + utils::[]( || $procfs_inst, $($args,)* diff --git a/src/utils/sysctl.rs b/src/utils/sysctl.rs index 54686fe4..46cc5513 100644 --- a/src/utils/sysctl.rs +++ b/src/utils/sysctl.rs @@ -82,6 +82,7 @@ mod tests { use crate::{ error::{Error, ErrorKind}, procfs::ProcfsHandle, + tests::common as tests_common, }; use once_cell::sync::Lazy; @@ -93,6 +94,9 @@ mod tests { #[test] fn bad_sysctl_file_noexist() { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "nonexistent.dummy.sysctl.path") .as_ref() @@ -111,6 +115,9 @@ mod tests { #[test] fn bad_sysctl_file_noread() { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "vm.drop_caches") .as_ref() @@ -129,6 +136,9 @@ mod tests { #[test] fn bad_sysctl_parse_invalid_multinumber() { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.printk").is_ok()); assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.printk") @@ -141,6 +151,9 @@ mod tests { #[test] fn bad_sysctl_parse_invalid_nonnumber() { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.random.uuid").is_ok()); assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.random.uuid") @@ -153,6 +166,9 @@ mod tests { #[test] fn sysctl_parse_int() { + // FIXME(libtest skip): use proper runtime skipping in the test. + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.pid_max").is_ok()); assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.pid_max").is_ok()); } From 9de3ae26f1341f149bee81efaad7a6a99894e472 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 16:35:57 +0200 Subject: [PATCH 06/11] e2e-tests: don't use /proc/sys for readlink tests This breaks under containers and there is no real need to use /proc/sys specifically for this test, though I guess we will eventually need to do skipping of e2e tests based on overmounts. Signed-off-by: Aleksa Sarai --- e2e-tests/tests/procfs-readlink.bats | 6 +++++- src/tests/common/procfs.rs | 1 - src/utils/sysctl.rs | 25 ++++++++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/e2e-tests/tests/procfs-readlink.bats b/e2e-tests/tests/procfs-readlink.bats index 42bba498..49f4d2f3 100644 --- a/e2e-tests/tests/procfs-readlink.bats +++ b/e2e-tests/tests/procfs-readlink.bats @@ -116,7 +116,11 @@ function teardown() { check-errno ENOENT [[ "$output" == *"error:"*"readlinkat"* ]] # Make sure the error is from readlinkat(2). - pathrs-cmd procfs --base root readlink sys/fs/overflowuid + pathrs-cmd procfs --base root readlink tty/drivers + check-errno ENOENT + [[ "$output" == *"error:"*"readlinkat"* ]] # Make sure the error is from readlinkat(2). + + pathrs-cmd procfs --base root readlink self/fdinfo/0 check-errno ENOENT [[ "$output" == *"error:"*"readlinkat"* ]] # Make sure the error is from readlinkat(2). diff --git a/src/tests/common/procfs.rs b/src/tests/common/procfs.rs index 39756482..1b9f74d6 100644 --- a/src/tests/common/procfs.rs +++ b/src/tests/common/procfs.rs @@ -49,7 +49,6 @@ pub(crate) fn has_proc_sys_overmounts() -> bool { // FIXME(libtest skip): Replace with this a panic-based runtime test skipping // harness, as this hotfix only works if the top-level #[test] function calls // this. -#[allow(clippy::unused_unit)] macro_rules! hotfix_skip_if_proc_sys_overmounts { (return $ret:expr) => { if $crate::tests::common::has_proc_sys_overmounts() { diff --git a/src/utils/sysctl.rs b/src/utils/sysctl.rs index 46cc5513..4eccbbe8 100644 --- a/src/utils/sysctl.rs +++ b/src/utils/sysctl.rs @@ -95,7 +95,10 @@ mod tests { #[test] fn bad_sysctl_file_noexist() { // FIXME(libtest skip): use proper runtime skipping in the test. - tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + #[allow(clippy::unused_unit)] + { + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + } assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "nonexistent.dummy.sysctl.path") @@ -116,7 +119,10 @@ mod tests { #[test] fn bad_sysctl_file_noread() { // FIXME(libtest skip): use proper runtime skipping in the test. - tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + #[allow(clippy::unused_unit)] + { + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + } assert_eq!( sysctl_read_parse::(&TEST_PROCFS_HANDLE, "vm.drop_caches") @@ -137,7 +143,10 @@ mod tests { #[test] fn bad_sysctl_parse_invalid_multinumber() { // FIXME(libtest skip): use proper runtime skipping in the test. - tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + #[allow(clippy::unused_unit)] + { + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + } assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.printk").is_ok()); assert_eq!( @@ -152,7 +161,10 @@ mod tests { #[test] fn bad_sysctl_parse_invalid_nonnumber() { // FIXME(libtest skip): use proper runtime skipping in the test. - tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + #[allow(clippy::unused_unit)] + { + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + } assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.random.uuid").is_ok()); assert_eq!( @@ -167,7 +179,10 @@ mod tests { #[test] fn sysctl_parse_int() { // FIXME(libtest skip): use proper runtime skipping in the test. - tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + #[allow(clippy::unused_unit)] + { + tests_common::hotfix_skip_if_proc_sys_overmounts!(return ()); + } assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.pid_max").is_ok()); assert!(sysctl_read_parse::(&TEST_PROCFS_HANDLE, "kernel.pid_max").is_ok()); From 21630f1bfd7de99541b1e08a63063c43adeeee9e Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:20 +0200 Subject: [PATCH 07/11] hack: rust-tests: add --help option Minor quality of life improvement when running this script from inside a container. Signed-off-by: Aleksa Sarai --- hack/rust-tests.sh | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/hack/rust-tests.sh b/hack/rust-tests.sh index 08822a4f..9a94b8f5 100755 --- a/hack/rust-tests.sh +++ b/hack/rust-tests.sh @@ -34,8 +34,12 @@ set -Eeuo pipefail SRC_ROOT="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..")" +function error() { + echo "[err]" "$@" >&2 +} + function bail() { - echo "rust tests: $*" >&2 + error "rust tests:" "$*" exit 1 } @@ -62,9 +66,22 @@ function strjoin() { echo "$str" } -TEMP="$(getopt -o sc:p:S: --long sudo,cargo:,partition:,enosys:,archive-file: -- "$@")" +TEMP="$(getopt -o h,sc:p:S: --long help,sudo,cargo:,partition:,enosys:,archive-file: -- "$@")" eval set -- "$TEMP" +function usage() { + [ "$#" -gt 0 ] && error "$@" + cat <] + [--partition=] + [--archive-file=] + [--enosys=,...] + [TESTS_TO_RUN]... +EOF + # shellcheck disable=SC2048 # We want to only expand to nothing or 1. + exit ${*:+1} +} + sudo= partition= enosys_syscalls=() @@ -92,12 +109,15 @@ while [ "$#" -gt 0 ]; do [ -n "$2" ] && enosys_syscalls+=("$2") shift 2 ;; + -h|--help) + usage + ;; --) shift break ;; *) - bail "unknown option $1" + usage "unknown option $1" esac done tests_to_run=("$@") From 7410107db1501c0f6c39f1f4f67bcdbb3a57dbf3 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:22 +0200 Subject: [PATCH 08/11] hack: rust-tests: auto-set _test_as_root if running as root In a container we actually do run as root by default, so we should handle that case automatically in a similar way to --sudo. Signed-off-by: Aleksa Sarai --- hack/rust-tests.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hack/rust-tests.sh b/hack/rust-tests.sh index 9a94b8f5..52f1525f 100755 --- a/hack/rust-tests.sh +++ b/hack/rust-tests.sh @@ -211,6 +211,8 @@ function nextest_run() { # This CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER magic lets us run # Rust tests as root without needing to run the build step as root. export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E " + elif [ "$(id -u)" -eq 0 ]; then + features+=("_test_as_root") fi build_args=() From 741c3e46475cdce55bbfe6eb54769658f45acc1c Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:24 +0200 Subject: [PATCH 09/11] hack: rust-tests: support --report-output-path This will be needed to make it easier to exfiltrate test data from containers into a volume without making targets/llvm-cov-target a volume (which has some other potential downsides). Signed-off-by: Aleksa Sarai --- hack/rust-tests.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/hack/rust-tests.sh b/hack/rust-tests.sh index 52f1525f..e8c512df 100755 --- a/hack/rust-tests.sh +++ b/hack/rust-tests.sh @@ -66,7 +66,7 @@ function strjoin() { echo "$str" } -TEMP="$(getopt -o h,sc:p:S: --long help,sudo,cargo:,partition:,enosys:,archive-file: -- "$@")" +TEMP="$(getopt -o h,sc:p:S: --long help,sudo,cargo:,partition:,enosys:,archive-file:,report-output-path: -- "$@")" eval set -- "$TEMP" function usage() { @@ -75,6 +75,7 @@ function usage() { Usage: $0 [--sudo] [--cargo=<$CARGO>] [--partition=] [--archive-file=] + [--report-output-path=] [--enosys=,...] [TESTS_TO_RUN]... EOF @@ -86,6 +87,7 @@ sudo= partition= enosys_syscalls=() nextest_archive= +report_output_path= CARGO="${CARGO_NIGHTLY:-cargo +nightly}" while [ "$#" -gt 0 ]; do case "$1" in @@ -109,6 +111,10 @@ while [ "$#" -gt 0 ]; do [ -n "$2" ] && enosys_syscalls+=("$2") shift 2 ;; + --report-output-path) + report_output_path="$2" + shift 2 + ;; -h|--help) usage ;; @@ -165,6 +171,7 @@ function llvm-profdata() { function merge_llvmcov_profdata() { local llvmcov_targetdir=target/llvm-cov-target + local report_output_path="${1:-$llvmcov_targetdir/libpathrs-combined.profraw}" # Get a list of *.profraw files for merging. local profraw_list @@ -182,7 +189,7 @@ function merge_llvmcov_profdata() { # Remove the old profiling data and replace it with the merged version. As # long as the file has a ".profraw" suffix, cargo-llvm-cov will use it. find "$llvmcov_targetdir" -name '*.profraw' -type f -delete - mv "$combined_profraw" "$llvmcov_targetdir/libpathrs-combined.profraw" + mv "$combined_profraw" "$report_output_path" } function nextest_run() { @@ -295,3 +302,8 @@ else nextest_run --no-fail-fast -E "not test(#tests::test_race_*)" nextest_run --no-fail-fast -E "test(#tests::test_race_*)" fi + +# Output the final report to the requested file. +if [ -n "$report_output_path" ]; then + merge_llvmcov_profdata "$report_output_path" +fi From e9bf98ceeb2a18d7bc4a68dba0bedb274dfb1437 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:26 +0200 Subject: [PATCH 10/11] ci: add Dockerfile The primary use-case for this image is for CI, but I've included a very minimal install image that you could in principle use to make use of libpathrs on container infrastructure without needing to build it yourself. Signed-off-by: Aleksa Sarai --- .dockerignore | 32 +++++++++++ Dockerfile | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..c32342f4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# git and GitHub-related files. +/.git* + +# No need to break the COPY cache for Docker-specific files. +/Dockerfile +/.dockerignore + +# Rust. +/target +**/*.rs.bk + +# Python +**/__pycache__/ +/contrib/bindings/python/dist +/contrib/bindings/python/*.egg-info +/contrib/bindings/python/*_cache + +# Releases directory. +/release + +# pkg-config generated by install.sh. +/pathrs.pc + +# nextest archives generated by CI. +/nextest-pathrs*.tar.zst + +# examples and e2e-test binaries. +/examples/*/cat +/examples/go/sysctl +/examples/c/cat_multithreaded +/e2e-tests/cmd/*/pathrs-cmd +/e2e-tests/cmd/python/.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7655eee1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: MPL-2.0 OR LGPL-3.0-or-later +# +# libpathrs: safe path resolution on Linux +# Copyright (C) 2026 Aleksa Sarai +# +# == MPL-2.0 == +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# +# Alternatively, this Source Code Form may also (at your option) be used +# under the terms of the GNU Lesser General Public License Version 3, as +# described below: +# +# == LGPL-3.0-or-later == +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or (at +# your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +ARG DEBIAN_RELEASE=trixie +ARG RUST_VERSION=1.96 + +# --------------------------------------------------------------------------- # +# build: builds libpathrs for use by CI and the "install" image. +# --------------------------------------------------------------------------- # +FROM rust:${RUST_VERSION}-${DEBIAN_RELEASE} AS build + +RUN apt-get update -y && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + clang \ + lld \ + make \ + pkg-config && \ + apt-get clean -y && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/libpathrs +COPY . /usr/src/libpathrs +RUN make release && \ + DESTDIR=/opt/libpathrs ./install.sh --prefix=/usr --libdir=/usr/lib + +# ---------------------------------------------------------------------------- +# install: minimal runtime image with libpathrs installed system-wide. +# Intended to be used as a base image by downstream projects on distros that do +# not ship a libpathrs package yet. +# ---------------------------------------------------------------------------- +FROM debian:${DEBIAN_RELEASE} AS install + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -y && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends \ + pkg-config && \ + apt-get clean -y && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=build /opt/libpathrs/ / +# debian doesn't use /usr/lib for the native architecture so we need to make +# sure it gets searched by the link loader with ldconfig. +RUN ldconfig + +# ---------------------------------------------------------------------------- +# ci: full test runner for CI and local test runs. +# This can run the Rust unit/integration tests and the e2e tests. +# ---------------------------------------------------------------------------- +ARG RUST_VERSION=1.96 +FROM rust:${RUST_VERSION}-${DEBIAN_RELEASE} AS ci + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + bats \ + curl \ + clang \ + git \ + golang-go \ + jq \ + lld \ + llvm \ + moreutils \ + python3 \ + python3-build \ + python3-dev \ + python3-pip \ + python3-setuptools \ + python3-venv \ + sudo && \ + apt-get clean -y && \ + rm -rf /var/lib/apt/lists/* + +# Use a globally-writable place for Go caches. +ENV GOCACHE=/tmp/go-cache/build +ENV GOMODCACHE=/tmp/go-cache/mod + +ARG CARGO_BINSTALL_VERSION=1.19.1 +RUN CARGO_BINSTALL_VERSION="$CARGO_BINSTALL_VERSION" \ + curl -L --proto '=https' --tlsv1.2 -sSf \ + "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/v$CARGO_BINSTALL_VERSION/install-from-binstall-release.sh" | bash + +ARG CARGO_LLVM_COV_VERSION=0.8.7 +ARG CARGO_HACK_VERSION=0.6.45 +ARG CARGO_NEXTEST_VERSION=0.9.137 +RUN cargo binstall --no-confirm \ + "cargo-llvm-cov@$CARGO_LLVM_COV_VERSION" \ + "cargo-hack@$CARGO_HACK_VERSION" \ + "cargo-nextest@$CARGO_NEXTEST_VERSION" + +ARG RUST_NIGHTLY=nightly-2026-06-03 +RUN rustup toolchain install "$RUST_NIGHTLY" && \ + rustup component add llvm-tools llvm-tools-preview && \ + rustup component add --toolchain "$RUST_NIGHTLY" llvm-tools llvm-tools-preview +ENV CARGO_NIGHTLY="cargo +$RUST_NIGHTLY" + +# We want the installed libpathrs library for the Python and Go tests. +COPY --from=build /opt/libpathrs/ / +# Debian doesn't use /usr/lib for the native architecture so we need to make +# sure it gets searched by the link loader with ldconfig. +RUN ldconfig + +WORKDIR /usr/src/libpathrs +COPY . /usr/src/libpathrs + +# Populate the cache for test runs and make sure the ownership is friendly for +# non-root. +FROM ci AS ci-with-cache +RUN cargo test --workspace --all-features --no-run && \ + $CARGO_NIGHTLY llvm-cov --workspace --doc --all-features --no-report && \ + find "$CARGO_HOME" /usr/src/libpathrs -type d -print0 | xargs -0 -P$(nproc) chmod a+rwx && \ + find "$CARGO_HOME" /usr/src/libpathrs -type f -print0 | xargs -0 -P$(nproc) chmod a+rw From a77345d2bb57ccbeb02000b7c018f73b4da475b9 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 17 Jun 2026 15:53:28 +0200 Subject: [PATCH 11/11] gha: run container tests in CI Signed-off-by: Aleksa Sarai --- .github/workflows/e2e-tests.yml | 77 ++++++++++++++++++++++- .github/workflows/rust.yml | 105 +++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f7c8bdfb..ef0424a7 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -24,6 +24,7 @@ name: e2e-tests env: BATS_VERSION: "1.11.1" + CI_IMAGE: cyphar/libpathrs:ci-latest jobs: e2e-test: @@ -34,7 +35,7 @@ jobs: - go - rust - python - runas: + run-as: - "" - "root" lang-desc: [""] @@ -60,7 +61,7 @@ jobs: ${{ format('({0}{1})', matrix.lang-desc || matrix.lang, - matrix.runas && format(', {0}', matrix.runas) || '', + matrix.run-as && format(', {0}', matrix.run-as) || '', ) }} runs-on: ubuntu-latest @@ -114,11 +115,81 @@ jobs: - name: make -C e2e-tests test-${{ matrix.lang }} run: |- export BATS=$(which bats) - make -C e2e-tests RUN_AS=${{ matrix.runas }} test-${{ matrix.lang }} + make -C e2e-tests RUN_AS=${{ matrix.run-as }} test-${{ matrix.lang }} + + ctr-ci-image: + runs-on: ubuntu-latest + name: build ci docker image + steps: + - uses: actions/checkout@v6 + - name: setup docker buildx + uses: docker/setup-buildx-action@v4 + - name: build and cache ci image + uses: docker/build-push-action@v7 + with: + context: . + tags: ${{ env.CI_IMAGE }} + cache-from: type=gha + cache-to: type=gha,mode=max + + ctr-e2e-test: + runs-on: ubuntu-latest + needs: + - ctr-ci-image + strategy: + fail-fast: false + matrix: + lang: + - python + - go + - rust + runtime: + - docker + run-as: + - unpriv + - CAP_SYS_ADMIN + env: + CONTAINER_RUNTIME: ${{ matrix.runtime }} + # NOTE: For the root tests we need to disable AppArmor because it blocks + # mount operations, even in child mount namespaces. + CONTAINER_RUN_ARGS: >- + ${{ matrix.run-as == 'CAP_SYS_ADMIN' && '--cap-add sys_admin --security-opt=apparmor=unconfined' || '' }} + ${{ matrix.run-as == 'unpriv' && '--user 1000:1000' || '' }} + E2E_LANG: ${{ matrix.lang }} + name: >- + (${{ matrix.runtime }}) + run e2e-tests + (${{ matrix.lang }}, ${{ matrix.run-as }}) + steps: + - uses: actions/checkout@v6 + # Pull the image from the cache by triggering a "new build". + - name: setup docker buildx + uses: docker/setup-buildx-action@v4 + # TODO: Ideally we would be able to pull the image from the cache without + # needing to trigger another build. In the worst case we could just + # upload the CI image in the ctr-ci-image job and load it here. + - name: build and cache ci image + uses: docker/build-push-action@v7 + with: + context: . + tags: ${{ env.CI_IMAGE }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + # Run the tests. + - run: >- + mkdir -p ./target && chmod a+rwx ./target + - name: ${{ matrix.runtime }} run ${{ matrix.lang }} e2e-tests (run as ${{ matrix.run-as }}) + run: >- + "$CONTAINER_RUNTIME" run --rm $CONTAINER_RUN_ARGS \ + -v $PWD/target:/usr/src/libpathrs/target \ + "$CI_IMAGE" \ + make -C e2e-tests "test-$E2E_LANG" e2e-complete: needs: - e2e-test + - ctr-e2e-test runs-on: ubuntu-latest steps: - run: echo "End-to-end test CI jobs completed successfully." diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 28179284..ef5e86dc 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -25,6 +25,7 @@ name: rust-ci env: RUST_MSRV: &RUST_MSRV "1.63" CBINDGEN_VERSION: "0.29.2" + CI_IMAGE: cyphar/libpathrs:ci-latest jobs: codespell: @@ -296,6 +297,7 @@ jobs: echo "data=$(jq -ScM 'map("\(.)")' <<<"$partitions")" >>"$GITHUB_OUTPUT" nextest: + runs-on: ubuntu-latest needs: - compute-test-partitions - nextest-archive @@ -329,7 +331,6 @@ jobs: matrix.enosys && format(', {0}=enosys', matrix.enosys) || '', ) }} - runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 # Nightly rust is required for llvm-cov --doc. @@ -385,6 +386,106 @@ jobs: slug: cyphar/libpathrs files: ${{ steps.codecov-coverage.outputs.file }} + ctr-ci-image: + runs-on: ubuntu-latest + name: build ci docker image + steps: + - uses: actions/checkout@v6 + - name: setup docker buildx + uses: docker/setup-buildx-action@v4 + - name: build and cache ci image + uses: docker/build-push-action@v7 + with: + context: . + tags: ${{ env.CI_IMAGE }} + cache-from: type=gha + cache-to: type=gha,mode=max + + ctr-nextest: + runs-on: ubuntu-latest + needs: + - ctr-ci-image + - compute-test-partitions + strategy: + fail-fast: false + matrix: + tests: ${{ fromJSON(needs.compute-test-partitions.outputs.tests) }} + runtime: + - docker + run-as: + - unpriv + - CAP_SYS_ADMIN + env: + NEXTEST_PATTERN_SPEC: ${{ fromJSON(matrix.tests).pattern }} + CONTAINER_RUNTIME: ${{ matrix.runtime }} + # NOTE: For the root tests we need to disable AppArmor because it blocks + # mount operations, even in child mount namespaces. + CONTAINER_RUN_ARGS: >- + ${{ matrix.run-as == 'CAP_SYS_ADMIN' && '--cap-add sys_admin --security-opt=apparmor=unconfined' || '' }} + ${{ matrix.run-as == 'unpriv' && '--user 1000:1000' || '' }} + name: >- + (${{ matrix.runtime }}) + cargo nextest + (${{ fromJSON(matrix.tests).name }}, ${{ matrix.run-as }}) + steps: + - uses: actions/checkout@v6 + # Nightly rust is required for llvm-cov --doc. + - uses: dtolnay/rust-toolchain@nightly + with: + components: llvm-tools + - uses: taiki-e/install-action@cargo-llvm-cov + - uses: taiki-e/install-action@nextest + - name: install llvm-tools wrappers + uses: taiki-e/install-action@v2 + with: + tool: cargo-binutils + # Pull the image from the cache by triggering a "new build". + - name: setup docker buildx + uses: docker/setup-buildx-action@v4 + # TODO: Ideally we would be able to pull the image from the cache without + # needing to trigger another build. In the worst case we could just + # upload the CI image in the ctr-ci-image job and load it here. + - name: build and cache ci image + uses: docker/build-push-action@v7 + with: + context: . + tags: ${{ env.CI_IMAGE }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + # Run the tests. + - run: >- + mkdir -p ./target && chmod a+rwx ./target + - name: ${{ matrix.runtime }} run ./hack/rust-tests.sh (run as ${{ matrix.run-as }}) + run: >- + "$CONTAINER_RUNTIME" run --rm $CONTAINER_RUN_ARGS \ + -v $PWD/target:/usr/src/libpathrs/target \ + "$CI_IMAGE" \ + ./hack/rust-tests.sh "$NEXTEST_PATTERN_SPEC" + - run: >- + sudo chown -R "$UID" ./target + + # Upload to CodeCov. + - name: generate codecov-friendly coverage + id: codecov-coverage + run: |- + CODECOV_FILE="$(mktemp coverage-codecov.lcov.txt.XXXXXX)" + cargo llvm-cov report --lcov --output-path="$CODECOV_FILE" + echo "file=$CODECOV_FILE" >>"$GITHUB_OUTPUT" + - name: upload rust coverage (codecov) + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: cyphar/libpathrs + files: ${{ steps.codecov-coverage.outputs.file }} + + - name: upload rust coverage (artifact) + uses: actions/upload-artifact@v7 + with: + name: profraw-${{ github.job }}-${{ strategy.job-index }} + path: "target/llvm-cov-target/*.profraw" + retention-days: 7 # no need to waste disk space + # Smoke-test for our %check section in the libpathrs RPM. # # TODO: I guess we should run this as root too... @@ -401,6 +502,7 @@ jobs: needs: - doctest - nextest + - ctr-nextest name: compute coverage runs-on: ubuntu-latest steps: @@ -545,6 +647,7 @@ jobs: - rustdoc - doctest - nextest + - ctr-nextest - cargo-test - coverage - examples