From 332a551dede061ff6049f31133fcbe8574948fb1 Mon Sep 17 00:00:00 2001 From: Aleksa Sarai Date: Fri, 26 Jul 2024 16:31:57 +1000 Subject: [PATCH] tests: add ProcfsHandle tests These tests are based on the filepath-securejoin tests, and notably include tests of the key cases when operating on procfs. Signed-off-by: Aleksa Sarai --- .github/workflows/ci.yml | 12 +- Cargo.toml | 4 + src/procfs.rs | 6 +- src/tests/common/mntns.rs | 117 ++++++++++++++++ src/tests/common/mod.rs | 3 + src/tests/mod.rs | 1 + src/tests/test_procfs.rs | 281 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 src/tests/common/mntns.rs create mode 100644 src/tests/test_procfs.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbdb9df9..8edbf90d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,11 +58,19 @@ jobs: - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest - - name: cargo nextest + - name: unit tests run: cargo llvm-cov --no-report nextest - - name: cargo test --doc + - name: doctests run: cargo llvm-cov --no-report --doc + # Run the unit tests as root. + # NOTE: Ideally this would be configured in .cargo/config.toml so it + # would also work locally, but unfortunately it seems cargo doesn't + # support cfg(feature=...) for target runner configs. + # See . + - name: unit tests (root) + run: CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER='sudo -E' cargo llvm-cov --no-report --features _test_as_root nextest + - name: calculate coverage run: cargo llvm-cov report - name: generate coverage html diff --git a/Cargo.toml b/Cargo.toml index 0ec41cb5..b7e04302 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ travis-ci = { repository = "openSUSE/libpathrs" } [lib] crate-type = ["rlib", "cdylib", "staticlib"] +[features] +# Only used for tests. +_test_as_root = [] + [profile.release] # Enable link-time optimisations. lto = true diff --git a/src/procfs.rs b/src/procfs.rs index aebd4a21..56b5b621 100644 --- a/src/procfs.rs +++ b/src/procfs.rs @@ -137,7 +137,7 @@ impl ProcfsHandle { // This is part of Linux's ABI. const PROC_ROOT_INO: u64 = 1; - fn new_fsopen() -> Result { + pub(crate) fn new_fsopen() -> Result { let sfd = syscalls::fsopen("proc", FsopenFlags::FSOPEN_CLOEXEC).context(error::RawOsSnafu { operation: "create procfs suberblock", @@ -164,7 +164,7 @@ impl ProcfsHandle { .and_then(Self::try_from) } - fn new_open_tree(flags: OpenTreeFlags) -> Result { + pub(crate) fn new_open_tree(flags: OpenTreeFlags) -> Result { syscalls::open_tree( -libc::EBADF, "/proc", @@ -177,7 +177,7 @@ impl ProcfsHandle { .and_then(Self::try_from) } - fn new_unsafe_open() -> Result { + pub(crate) fn new_unsafe_open() -> Result { syscalls::openat(libc::AT_FDCWD, "/proc", libc::O_PATH | libc::O_DIRECTORY, 0) .context(error::RawOsSnafu { operation: "open /proc handle", diff --git a/src/tests/common/mntns.rs b/src/tests/common/mntns.rs new file mode 100644 index 00000000..a33a5a9c --- /dev/null +++ b/src/tests/common/mntns.rs @@ -0,0 +1,117 @@ +/* + * libpathrs: safe path resolution on Linux + * Copyright (C) 2019-2024 Aleksa Sarai + * Copyright (C) 2019-2024 SUSE LLC + * + * 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 std::{ + ffi::CString, + fs::File, + io::Error as IOError, + os::fd::{AsRawFd, RawFd}, + path::{Path, PathBuf}, + ptr, +}; + +use crate::syscalls; + +use anyhow::Error; +use libc::c_int; + +unsafe fn unshare(flags: c_int) -> Result<(), IOError> { + // SAFETY: Caller guarantees that this unshare operation is safe. + let ret = unsafe { libc::unshare(flags) }; + let err = IOError::last_os_error(); + if ret >= 0 { + Ok(()) + } else { + Err(err) + } +} + +unsafe fn setns(fd: RawFd, flags: c_int) -> Result<(), IOError> { + // SAFETY: Caller guarantees that this setns operation is safe. + let ret = unsafe { libc::setns(fd, flags) }; + let err = IOError::last_os_error(); + if ret >= 0 { + Ok(()) + } else { + Err(err) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum MountType { + Tmpfs, + Bind { src: PathBuf }, +} + +pub(crate) fn mount>(dst: P, ty: MountType) -> Result<(), Error> { + let dst = dst.as_ref(); + let dst_file = syscalls::openat(libc::AT_FDCWD, dst, libc::O_NOFOLLOW | libc::O_PATH, 0)?; + let dst_path = CString::new(format!("/proc/self/fd/{}", dst_file.as_raw_fd()))?; + + let ret = match ty { + MountType::Tmpfs => unsafe { + libc::mount( + CString::new("")?.as_ptr(), + dst_path.as_ptr(), + CString::new("tmpfs")?.as_ptr(), + 0, + ptr::null(), + ) + }, + MountType::Bind { src } => { + let src_file = + syscalls::openat(libc::AT_FDCWD, src, libc::O_NOFOLLOW | libc::O_PATH, 0)?; + let src_path = CString::new(format!("/proc/self/fd/{}", src_file.as_raw_fd()))?; + unsafe { + libc::mount( + src_path.as_ptr(), + dst_path.as_ptr(), + ptr::null(), + libc::MS_BIND, + ptr::null(), + ) + } + } + }; + let err = IOError::last_os_error(); + + if ret >= 0 { + Ok(()) + } else { + Err(err.into()) + } +} + +pub(crate) fn in_mnt_ns(func: F) -> Result +where + F: FnOnce() -> Result, +{ + let old_ns = File::open("/proc/self/ns/mnt")?; + + // TODO: Run this in a subprocess. + + unsafe { unshare(libc::CLONE_FS | libc::CLONE_NEWNS) } + .expect("unable to create a mount namespace"); + + let ret = func(); + + unsafe { setns(old_ns.as_raw_fd(), libc::CLONE_NEWNS) } + .expect("unable to rejoin old namespace"); + + ret +} diff --git a/src/tests/common/mod.rs b/src/tests/common/mod.rs index 396fa1a9..9b019de5 100644 --- a/src/tests/common/mod.rs +++ b/src/tests/common/mod.rs @@ -18,3 +18,6 @@ mod root; pub(crate) use root::*; + +mod mntns; +pub(in crate::tests) use mntns::*; diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 3e3bb351..a10eba8f 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -18,4 +18,5 @@ pub(crate) mod common; +mod test_procfs; mod test_resolve; diff --git a/src/tests/test_procfs.rs b/src/tests/test_procfs.rs new file mode 100644 index 00000000..5362eb29 --- /dev/null +++ b/src/tests/test_procfs.rs @@ -0,0 +1,281 @@ +/* + * libpathrs: safe path resolution on Linux + * Copyright (C) 2019-2024 Aleksa Sarai + * Copyright (C) 2019-2024 SUSE LLC + * + * 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::{ + error::ErrorKind, + procfs::{ProcfsBase, ProcfsHandle}, + resolvers::procfs::ProcfsResolver, + syscalls::{self, OpenTreeFlags}, + OpenFlags, +}; +use utils::ExpectedResult; + +use anyhow::Error; + +macro_rules! procfs_test { + // Create the actual test functions. + (@fn [<$func_prefix:ident $test_name:ident>] $procfs_var:ident = { $procfs_inst:expr } <- $do_test:expr => (over_mounts: $over_mounts:expr, error: $expect_error:expr) ;) => { + paste::paste! { + #[test] + #[cfg_attr(not(feature = "_test_as_root"), ignore)] + fn [<$func_prefix $test_name>]() -> Result<(), Error> { + utils::in_mnt_ns_with_overmounts($over_mounts, ExpectedResult::$expect_error, || { + let $procfs_var = { $procfs_inst } ?; + $do_test + }) + } + + #[test] + #[cfg_attr(not(feature = "_test_as_root"), ignore)] + fn [<$func_prefix openat2_ $test_name>]() -> Result<(), Error> { + if !*syscalls::OPENAT2_IS_SUPPORTED { + // skip this test + return Ok(()); + } + utils::in_mnt_ns_with_overmounts($over_mounts, ExpectedResult::$expect_error, || { + let mut $procfs_var = { $procfs_inst } ?; + // Force openat2 resolver. + $procfs_var.resolver = ProcfsResolver::Openat2; + $do_test + }) + } + + #[test] + #[cfg_attr(not(feature = "_test_as_root"), ignore)] + fn [<$func_prefix opath_ $test_name>]() -> Result<(), Error> { + utils::in_mnt_ns_with_overmounts($over_mounts, ExpectedResult::$expect_error, || { + let mut $procfs_var = { $procfs_inst } ?; + // Force opath resolver. + $procfs_var.resolver = ProcfsResolver::RestrictedOpath; + $do_test + }) + } + } + }; + + // Create a test for each ProcfsHandle::new_* method. + (@impl $test_name:ident $procfs_var:ident <- $do_test:expr => ($($tt:tt)*) ;) => { + procfs_test! { + @fn [] + $procfs_var = { ProcfsHandle::new() } <- $do_test => (over_mounts: false, $($tt)*); + } + + procfs_test! { + @fn [] + $procfs_var = { ProcfsHandle::new_fsopen() } <- $do_test => (over_mounts: false, $($tt)*); + } + + procfs_test! { + @fn [] + $procfs_var = { + ProcfsHandle::new_open_tree(OpenTreeFlags::OPEN_TREE_CLONE) + } <- $do_test => (over_mounts: false, $($tt)*); + } + + procfs_test! { + @fn [] + $procfs_var = { + ProcfsHandle::new_open_tree(OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::AT_RECURSIVE) + } <- $do_test => (over_mounts: true, $($tt)*); + } + + procfs_test! { + @fn [] + $procfs_var = { ProcfsHandle::new_unsafe_open() } <- $do_test => (over_mounts: true, $($tt)*); + } + }; + + // procfs_test! { abc: readlink("foo") => (error: ExpectedResult::Some(ErrorKind::OsError(Some(libc::ENOENT)))) } + ($test_name:ident : readlink ( $path:expr ) => ($($tt:tt)*)) => { + paste::paste! { + procfs_test! { + @impl [] + procfs <- procfs.readlink(ProcfsBase::ProcSelf, $path) => ($($tt)*); + } + procfs_test! { + @impl [] + procfs <- procfs.readlink(ProcfsBase::ProcThreadSelf, $path) => ($($tt)*); + } + } + }; + + // procfs_test! { xyz: open("self/fd", O_DIRECTORY) => (error: None) } + ($test_name:ident : open ( $path:expr, $($flag:ident)|* ) => ($($tt:tt)*)) => { + paste::paste! { + procfs_test! { + @impl [] + procfs <- procfs.open(ProcfsBase::ProcSelf, $path, $(OpenFlags::$flag)|*) => ($($tt)*); + } + procfs_test! { + @impl [] + procfs <- procfs.open(ProcfsBase::ProcThreadSelf, $path, $(OpenFlags::$flag)|*) => ($($tt)*); + } + } + }; + + // procfs_test! { def: open_follow("self/exe", O_DIRECTORY | O_PATH) => (error: ErrorKind::OsError(Some(libc::ENOTDIR) } + ($test_name:ident : open_follow ( $path:expr, $($flag:ident)|* ) => ($($tt:tt)*)) => { + paste::paste! { + procfs_test! { + @impl [] + procfs <- procfs.open_follow(ProcfsBase::ProcSelf, $path, $(OpenFlags::$flag)|*) => ($($tt)*); + } + procfs_test! { + @impl [] + procfs <- procfs.open_follow(ProcfsBase::ProcThreadSelf, $path, $(OpenFlags::$flag)|*) => ($($tt)*); + } + } + }; +} + +// Non-procfs overmount. +procfs_test! { tmpfs_dir: open("fdinfo", O_DIRECTORY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { tmpfs_dir: open_follow("fdinfo", O_DIRECTORY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +// No overmounts. +procfs_test! { nomount: open("attr/current", O_RDONLY) => (error: Ok) } +procfs_test! { nomount: open_follow("attr/current", O_RDONLY) => (error: Ok) } +// Procfs regular file overmount. +procfs_test! { proc_file_wr: open("attr/exec", O_WRONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { proc_file_wr: open_follow("attr/exec", O_WRONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { proc_file_rd: open("mountinfo", O_RDONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { proc_file_rd: open_follow("mountinfo", O_RDONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +// Magic-links with no overmount. +procfs_test! { magiclink_nomount: open("cwd", O_PATH) => (error: Ok) } +procfs_test! { magiclink_nomount: open_follow("cwd", O_RDONLY) => (error: Ok) } +procfs_test! { magiclink_nomount: readlink("cwd") => (error: Ok) } +procfs_test! { magiclink_nomount_fd1: readlink("fd/1") => (error: Ok) } +procfs_test! { magiclink_nomount_fd2: readlink("fd/2") => (error: Ok) } +// Magic-links with overmount. +procfs_test! { magiclink_exe: open("exe", O_PATH) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { magiclink_exe: open_follow("exe", O_RDONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { magiclink_exe: readlink("exe") => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { magiclink_fd0: open("fd/0", O_PATH) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { magiclink_fd0: open_follow("fd/0", O_RDONLY) => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { magiclink_fd0: readlink("fd/0") => (error: ErrOvermount(ErrorKind::OsError(Some(libc::EXDEV)))) } +// Behaviour-related testing. +procfs_test! { proc_cwd_trailing_slash: open_follow("cwd/", O_RDONLY) => (error: Ok) } +procfs_test! { proc_fdlink_trailing_slash: open_follow("fd//1/", O_RDONLY) => (error: Err(ErrorKind::OsError(Some(libc::ENOTDIR)))) } +// TODO: root can always open procfs files with O_RDWR even if writes fail. +//procfs_test! { proc_nowrite: open("status", O_RDWR) => (error: Err(ErrorKind::OsError(Some(libc::EACCES)))) } +procfs_test! { proc_dotdot_escape: open_follow("../..", O_PATH) => (error: Err(ErrorKind::OsError(Some(libc::EXDEV)))) } +// TODO: openat2(RESOLVE_BENEATH) seems to handle "fd/../.." incorrectly (-EAGAIN), and "fd/.." is allowed. +//procfs_test! { proc_dotdot_escape: open_follow("fd/../..", O_PATH) => (error: Err(ErrorKind::OsError(Some(libc::EXDEV)))) } +//procfs_test! { proc_dotdot: open_follow("fd/..", O_PATH) => (error: Err(ErrorKind::OsError(Some(libc::EXDEV)))) } +procfs_test! { proc_magic_component: open("root/etc/passwd", O_RDONLY) => (error: Err(ErrorKind::OsError(Some(libc::ELOOP)))) } +procfs_test! { proc_magic_component: open_follow("root/etc/passwd", O_RDONLY) => (error: Err(ErrorKind::OsError(Some(libc::ELOOP)))) } +procfs_test! { proc_magic_component: readlink("root/etc/passwd") => (error: Err(ErrorKind::OsError(Some(libc::ELOOP)))) } +procfs_test! { proc_sym_onofollow: open("fd/1", O_RDONLY) => (error: Err(ErrorKind::OsError(Some(libc::ELOOP)))) } +procfs_test! { proc_sym_opath_onofollow: open("fd/1", O_PATH) => (error: Ok) } +procfs_test! { proc_sym_odir_opath_onofollow: open("fd/1", O_DIRECTORY|O_PATH) => (error: Err(ErrorKind::OsError(Some(libc::ENOTDIR)))) } +procfs_test! { proc_dir_odir_opath_onofollow: open("fd", O_DIRECTORY|O_PATH) => (error: Ok) } + +mod utils { + use std::{fmt::Debug, path::PathBuf}; + + use crate::{ + error::{Error as PathrsError, ErrorKind}, + tests::common::{self as tests_common, MountType}, + }; + + use anyhow::Error; + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + pub(super) enum ExpectedResult { + Ok, + Err(ErrorKind), + ErrOvermount(ErrorKind), + } + + fn check_proc_error( + res: Result, + over_mounts: bool, + expected: ExpectedResult, + ) { + let want_error = match expected { + ExpectedResult::Ok => None, + ExpectedResult::Err(kind) => Some(kind), + ExpectedResult::ErrOvermount(kind) => { + if over_mounts { + Some(kind) + } else { + None + } + } + }; + assert_eq!( + res.as_ref().err().map(PathrsError::kind), + want_error, + "unexpected result for overmounts={} got {:?} (expected error {:?})", + over_mounts, + res, + expected + ); + } + + pub(super) fn in_mnt_ns_with_overmounts( + are_over_mounts_visible: bool, + expected: ExpectedResult, + func: F, + ) -> Result<(), Error> + where + T: Debug, + F: FnOnce() -> Result, + { + tests_common::in_mnt_ns(|| { + // Add some overmounts to /proc/self and /proc/thread-self. + for prefix in vec!["/proc/self", "/proc/thread-self"] { + let prefix = PathBuf::from(prefix); + + // A tmpfs on top of /proc/.../fdinfo. + tests_common::mount(prefix.join("fdinfo"), MountType::Tmpfs)?; + // A bind-mount of a real procfs file that ignores all writes. + tests_common::mount( + prefix.join("attr/exec"), + MountType::Bind { + src: "/proc/1/sched".into(), + }, + )?; + // A bind-mount of a real procfs file that can have custom data. + tests_common::mount( + prefix.join("mountinfo"), + MountType::Bind { + src: "/proc/1/environ".into(), + }, + )?; + // Magic-link overmounts. + tests_common::mount( + prefix.join("exe"), + MountType::Bind { + src: "/proc/1/fd/0".into(), + }, + )?; + tests_common::mount( + prefix.join("fd/0"), + MountType::Bind { + src: "/proc/1/exe".into(), + }, + )?; + // TODO: Add some tests for mounts on top of /proc/self. + } + + let res = func(); + check_proc_error(res, are_over_mounts_visible, expected); + Ok(()) + }) + } +}