From a8afaa77f9363065dc26a6cf122c32df96f19f33 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 3 Jun 2026 23:39:38 +1000 Subject: [PATCH 1/8] 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 it. Assisted-by: Claude:claude-opus-4.8 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 eeab561a19df9417b2f66e73a890e51a58a76a1a Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 3 Jun 2026 01:01:25 +1000 Subject: [PATCH 2/8] 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 bbf58d79152d4f94721e8e06b6377d0c9b30e3eb Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Tue, 2 Jun 2026 18:41:14 +1000 Subject: [PATCH 3/8] 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 44cb57f0..c2dbf839 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 2a292631..981a81c2 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 37729fb45e5ae994cd64c491eb83931b126a4fa8 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Tue, 2 Jun 2026 18:42:01 +1000 Subject: [PATCH 4/8] 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 92f7b2de73ff2285ea309d964a6aab769d4c6b50 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Tue, 2 Jun 2026 18:50:14 +1000 Subject: [PATCH 5/8] 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 6337160653a73ec70e33b44b6db2582a2b391e37 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 3 Jun 2026 00:44:58 +1000 Subject: [PATCH 6/8] 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 2789c80d1aff9caeb57ea9e3f36af6694e451fb1 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 3 Jun 2026 00:47:38 +1000 Subject: [PATCH 7/8] 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 | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 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..74196c4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,139 @@ +# 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/* + +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 b8c3ad01d4f1fc43a40007fbc6e09cc0692773e0 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Wed, 3 Jun 2026 01:25:16 +1000 Subject: [PATCH 8/8] 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 c2dbf839..2f60a490 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