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 781af3c6..dac9d815 100644 --- a/src/procfs.rs +++ b/src/procfs.rs @@ -136,7 +136,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", @@ -163,7 +163,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", @@ -176,7 +176,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..b587fa21 --- /dev/null +++ b/src/tests/common/mntns.rs @@ -0,0 +1,118 @@ +/* + * 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, + io::Write, + 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 ee14b0b6..a5a3f348 100644 --- a/src/tests/common/mod.rs +++ b/src/tests/common/mod.rs @@ -18,3 +18,6 @@ mod root; pub(in crate::tests) use root::*; + +mod mntns; +pub(in crate::tests) use mntns::*; diff --git a/src/tests/mod.rs b/src/tests/mod.rs index d3bd11a4..d1daf92b 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -18,4 +18,5 @@ 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..5f9fd86c --- /dev/null +++ b/src/tests/test_procfs.rs @@ -0,0 +1,240 @@ +/* + * 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::{ + procfs::{ProcfsBase, ProcfsHandle}, + syscalls::OpenTreeFlags, +}; + +use std::fs::File; + +use anyhow::Error; + +#[test] +fn procfs_unpriv_new() -> Result<(), Error> { + let procfs = ProcfsHandle::new()?; + utils::check_procfs(&procfs, false) +} + +#[test] +fn procfs_unpriv_new_unsafe_open() -> Result<(), Error> { + let procfs = ProcfsHandle::new_unsafe_open()?; + utils::check_procfs(&procfs, false) +} + +#[test] +#[cfg_attr(not(feature = "_test_as_root"), ignore)] +fn procfs_overmounts_new() -> Result<(), Error> { + utils::mntns_check_procfs_fn(ProcfsHandle::new, false) +} + +#[test] +#[cfg_attr(not(feature = "_test_as_root"), ignore)] +fn procfs_overmounts_new_fsopen() -> Result<(), Error> { + utils::mntns_check_procfs_fn(ProcfsHandle::new_fsopen, false) +} + +#[test] +#[cfg_attr(not(feature = "_test_as_root"), ignore)] +fn procfs_overmounts_new_open_tree() -> Result<(), Error> { + utils::mntns_check_procfs_fn( + || ProcfsHandle::new_open_tree(OpenTreeFlags::OPEN_TREE_CLONE), + false, + ) +} + +#[test] +#[cfg_attr(not(feature = "_test_as_root"), ignore)] +fn procfs_overmounts_new_open_tree_recursive() -> Result<(), Error> { + utils::mntns_check_procfs_fn( + || { + ProcfsHandle::new_open_tree( + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::AT_RECURSIVE, + ) + }, + true, + ) +} + +#[test] +#[cfg_attr(not(feature = "_test_as_root"), ignore)] +fn procfs_overmounts_new_unsafe_open() -> Result<(), Error> { + utils::mntns_check_procfs_fn(ProcfsHandle::new_unsafe_open, true) +} + +mod utils { + use std::{ + fs::File, + io, + os::linux::fs::MetadataExt, + path::{Path, PathBuf}, + }; + + use crate::{ + error::Error as PathrsError, + procfs::{ProcfsBase, ProcfsHandle}, + tests::common::{self as tests_common, MountType}, + utils::RawFdExt, + Handle, OpenFlags, Root, + }; + + use anyhow::Error; + use errno::Errno; + + fn check_procfs_open>( + procfs: &ProcfsHandle, + procfs_base: ProcfsBase, + path: P, + flags: OpenFlags, + expect_error: bool, + ) { + let path = path.as_ref(); + + let res = if flags.contains(OpenFlags::O_NOFOLLOW) { + procfs.open(procfs_base, path, flags) + } else { + procfs.open_follow(procfs_base, path, flags) + }; + assert_eq! { + res.is_err(), expect_error, + "unexpected result from ProcfsHandle::open(/proc/{:?}/{:?}, {:?}) -- got {:?} (expected error? {})", + procfs_base.into_path(None).display(), path.display(), flags, res, expect_error, + } + } + + fn check_procfs_readlink>( + procfs: &ProcfsHandle, + procfs_base: ProcfsBase, + path: P, + expect_error: bool, + ) { + let path = path.as_ref(); + + let res = procfs.readlink(procfs_base, path); + assert_eq! { + res.is_err(), expect_error, + "unexpected result from ProcfsHandle::readlink(/proc/{:?}/{:?}) -- got {:?} (expected error? {})", + procfs_base.into_path(None).display(), path.display(), res, expect_error, + } + } + + pub(super) fn check_procfs( + procfs: &ProcfsHandle, + expect_overmounts: bool, + ) -> Result<(), Error> { + // TODO: Switch to macro. + for base in vec![ProcfsBase::ProcSelf, ProcfsBase::ProcThreadSelf] { + // tmpfs overmount + check_procfs_open( + procfs, + base, + "fdinfo", + OpenFlags::O_NOFOLLOW | OpenFlags::O_DIRECTORY, + expect_overmounts, + ); + // procfs regular file overmount + check_procfs_open(procfs, base, "attr/current", OpenFlags::O_WRONLY, false); + check_procfs_open( + procfs, + base, + "attr/exec", + OpenFlags::O_WRONLY | OpenFlags::O_NOFOLLOW, + expect_overmounts, + ); + check_procfs_open( + procfs, + base, + "attr/exec", + OpenFlags::O_WRONLY, + expect_overmounts, + ); + check_procfs_open( + procfs, + base, + "mountinfo", + OpenFlags::O_RDONLY | OpenFlags::O_NOFOLLOW, + expect_overmounts, + ); + check_procfs_open( + procfs, + base, + "mountinfo", + OpenFlags::O_RDONLY, + expect_overmounts, + ); + // magic-link overmount + check_procfs_open(procfs, base, "cwd", OpenFlags::O_DIRECTORY, false); + check_procfs_readlink(procfs, base, "cwd", false); + check_procfs_open(procfs, base, "exe", OpenFlags::O_RDONLY, expect_overmounts); + check_procfs_readlink(procfs, base, "exe", expect_overmounts); + check_procfs_open(procfs, base, "fd/0", OpenFlags::O_RDONLY, expect_overmounts); + check_procfs_readlink(procfs, base, "fd/0", expect_overmounts); + check_procfs_readlink(procfs, base, "fd/1", false); + } + Ok(()) + } + + pub(super) fn mntns_check_procfs_fn( + procfs_get: F, + expect_overmounts: bool, + ) -> Result<(), Error> + where + 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 procfs = procfs_get()?; + check_procfs(&procfs, expect_overmounts) + }) + } +}