diff --git a/Cargo.lock b/Cargo.lock index 004b60a97..df0e4d76f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4311,6 +4311,7 @@ dependencies = [ "jsonc-parser", "libc", "libtest-mimic", + "nix 0.31.2", "notify", "preload_test_lib", "pty_terminal", diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index 7c5ed30a4..5bdff9df0 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -2,7 +2,7 @@ mod test_utils; use std::{ env::current_dir, - fs::{File, OpenOptions}, + fs::{self, File, OpenOptions}, process::Stdio, }; @@ -35,6 +35,22 @@ async fn open_write() -> anyhow::Result<()> { Ok(()) } +#[test(tokio::test)] +async fn metadata() -> anyhow::Result<()> { + let tmp_dir = tempfile::tempdir()?; + let tmp_path = tmp_dir.path().join("hello"); + File::create(&tmp_path)?; + let tmp_path_str = tmp_path.to_str().unwrap().to_owned(); + + let accesses = track_fn!(tmp_path_str, |tmp_path_str: String| { + let _ = fs::metadata(tmp_path_str); + }) + .await?; + assert_contains(&accesses, tmp_path.as_path(), AccessMode::READ); + + Ok(()) +} + #[test(tokio::test)] async fn readdir() -> anyhow::Result<()> { let tmpdir = tempfile::tempdir()?; diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index e4d9603ec..0731017d7 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -2,7 +2,10 @@ use fspy_shared::ipc::AccessMode; use libc::{c_char, c_int, c_long}; use crate::{ - client::{convert::PathAt, handle_open}, + client::{ + convert::{Fd, PathAt}, + handle_open, + }, macros::intercept, }; @@ -23,16 +26,26 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { let a5 = unsafe { args.next_arg::() }; if syscall_no == libc::SYS_statx { - // c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD) + // C-style conversions are expected for the variadic syscall arguments. #[expect( clippy::cast_possible_truncation, - reason = "c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD)" + reason = "C-style conversion from c_long syscall arguments to c_int" )] let dirfd = a0 as c_int; let pathname = a1 as *const c_char; - // SAFETY: pathname is a valid pointer to a null-terminated C string provided via the syscall arguments - unsafe { - handle_open(PathAt(dirfd, pathname), AccessMode::READ); + #[expect( + clippy::cast_possible_truncation, + reason = "C-style conversion from c_long syscall arguments to c_int" + )] + let flags = a2 as c_int; + if pathname.is_null() { + if flags & libc::AT_EMPTY_PATH != 0 { + // SAFETY: dirfd is provided by the statx syscall caller. + unsafe { handle_open(Fd(dirfd), AccessMode::READ) }; + } + } else { + // SAFETY: pathname is a non-null C string pointer provided by the statx syscall caller. + unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ) }; } } // SAFETY: forwarding the syscall to the original libc syscall function with the extracted arguments diff --git a/crates/fspy_preload_unix/src/interceptions/stat.rs b/crates/fspy_preload_unix/src/interceptions/stat.rs index fe05c2b00..ac4af7651 100644 --- a/crates/fspy_preload_unix/src/interceptions/stat.rs +++ b/crates/fspy_preload_unix/src/interceptions/stat.rs @@ -1,6 +1,8 @@ use fspy_shared::ipc::AccessMode; use libc::{c_char, c_int, stat as stat_struct}; +#[cfg(target_os = "linux")] +use crate::client::convert::Fd; use crate::{ client::{convert::PathAt, handle_open}, macros::intercept, @@ -41,3 +43,40 @@ unsafe extern "C" fn fstatat( // SAFETY: calling the original libc fstatat() with the same arguments forwarded from the interposed function unsafe { fstatat::original()(dirfd, pathname, buf, flags) } } + +#[cfg(target_os = "linux")] +intercept!(statx: unsafe extern "C" fn( + dirfd: c_int, + pathname: *const c_char, + flags: c_int, + mask: libc::c_uint, + statxbuf: *mut libc::statx, +) -> c_int); +#[cfg(target_os = "linux")] +unsafe extern "C" fn statx( + dirfd: c_int, + pathname: *const c_char, + flags: c_int, + mask: libc::c_uint, + statxbuf: *mut libc::statx, +) -> c_int { + let Some(original) = statx::try_original() else { + // Rust's standard library interprets ENOSYS from its statx availability + // probe as unsupported and falls back to stat64. + // SAFETY: __errno_location returns the calling thread's errno storage on Linux. + unsafe { *libc::__errno_location() = libc::ENOSYS }; + return -1; + }; + + if pathname.is_null() { + if flags & libc::AT_EMPTY_PATH != 0 { + // SAFETY: dirfd is provided by the statx caller. + unsafe { handle_open(Fd(dirfd), AccessMode::READ) }; + } + } else { + // SAFETY: pathname is a non-null C string pointer provided by the statx caller. + unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ) }; + } + // SAFETY: calling the original libc statx() with the same arguments forwarded from the interposed function + unsafe { original(dirfd, pathname, flags, mask, statxbuf) } +} diff --git a/crates/fspy_preload_unix/src/macros/linux.rs b/crates/fspy_preload_unix/src/macros/linux.rs index 34f748c68..72881d62a 100644 --- a/crates/fspy_preload_unix/src/macros/linux.rs +++ b/crates/fspy_preload_unix/src/macros/linux.rs @@ -28,7 +28,10 @@ macro_rules! intercept { #[cfg(test)] #[test] fn symbol_64_does_not_exist() { - ::core::assert_eq!($crate::macros::symbol_exists(::core::stringify!($name)), false); + ::core::assert_eq!( + $crate::macros::symbol_exists(::core::concat!(::core::stringify!($name), 64)), + false, + ); } } }; @@ -47,7 +50,7 @@ pub fn symbol_exists(name: &str) -> bool { } macro_rules! intercept_inner { - ($name: ident: $fn_sig: ty; $test_fn: item ) => { + ($name: ident: $fn_sig: ty; $test_fn: item) => { const _: $fn_sig = $name; const _: $fn_sig = $crate::libc::$name; @@ -66,17 +69,44 @@ macro_rules! intercept_inner { #[expect(clippy::allow_attributes, reason = "using allow because unused_imports may or may not fire depending on macro expansion")] #[allow(unused_imports, reason = "glob import brings types into scope for macro-generated code")] use super::*; + #[expect( + clippy::allow_attributes, + reason = "using allow because dead_code only fires for optional original symbols" + )] + #[allow( + dead_code, + reason = "not every interposer forwards to its generated original function" + )] pub unsafe fn original() -> $fn_sig { - static LAZY: std::sync::LazyLock<$fn_sig> = std::sync::LazyLock::new(|| - // SAFETY: dlsym with RTLD_NEXT returns the next symbol in the dynamic linking order, - // and transmute converts the resulting function pointer to the expected function signature. - // The caller guarantees the symbol name matches the expected function signature via the macro invocation. - unsafe { - ::core::mem::transmute(::libc::dlsym( - ::libc::RTLD_NEXT, - ::core::concat!(::core::stringify!($name), "\0").as_ptr().cast(), + try_original().unwrap_or_else(|| { + panic!(::core::concat!( + "original symbol not found: ", + ::core::stringify!($name) )) - }); + }) + } + pub fn try_original() -> ::core::option::Option<$fn_sig> { + static LAZY: std::sync::LazyLock<::core::option::Option<$fn_sig>> = + std::sync::LazyLock::new(|| { + // SAFETY: dlsym with RTLD_NEXT returns the next symbol in the dynamic + // linking order. A non-null pointer has the signature checked by the + // macro invocation. + let symbol = unsafe { + ::libc::dlsym( + ::libc::RTLD_NEXT, + ::core::concat!(::core::stringify!($name), "\0").as_ptr().cast(), + ) + }; + if symbol.is_null() { + ::core::option::Option::None + } else { + // SAFETY: the symbol name and function signature are paired by the + // macro invocation, and null was checked above. + ::core::option::Option::Some(unsafe { + ::core::mem::transmute::<*mut ::libc::c_void, $fn_sig>(symbol) + }) + } + }); *LAZY } $test_fn diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index a0d8b8ba3..3d179de0d 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -31,6 +31,9 @@ vite_str = { workspace = true } vite_task = { workspace = true } which = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["mount", "sched", "user"] } + [dev-dependencies] cow-utils = { workspace = true } cp_r = { workspace = true } diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index c2d3e2156..9e3c7f6fb 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -23,7 +23,10 @@ mod print_file; mod read_stdin; mod replace_file_content; mod rm; +#[cfg(target_os = "linux")] +mod small_dev_shm; mod stat_file; +mod stat_long_filename; mod touch_file; mod write_file; @@ -32,7 +35,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, small_dev_shm, stat-file, stat_long_filename, touch-file, write-file" ); std::process::exit(1); } @@ -64,10 +67,15 @@ fn main() { "read-stdin" => read_stdin::run(), "replace-file-content" => replace_file_content::run(&args[2..]), "rm" => rm::run(&args[2..]), + #[cfg(target_os = "linux")] + "small_dev_shm" => small_dev_shm::run(&args[2..]).map_err(Into::into), + #[cfg(not(target_os = "linux"))] + "small_dev_shm" => Err("vtt small_dev_shm is only supported on Linux".into()), "stat-file" => { stat_file::run(&args[2..]); Ok(()) } + "stat_long_filename" => stat_long_filename::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vite_task_bin/src/vtt/small_dev_shm.rs b/crates/vite_task_bin/src/vtt/small_dev_shm.rs new file mode 100644 index 000000000..d859b692a --- /dev/null +++ b/crates/vite_task_bin/src/vtt/small_dev_shm.rs @@ -0,0 +1,58 @@ +#![cfg(target_os = "linux")] + +use std::{os::unix::process::ExitStatusExt as _, process::Command}; + +use anyhow::{Context as _, Result}; +use nix::{ + mount::{MsFlags, mount}, + sched::{CloneFlags, unshare}, + unistd::{Gid, Uid}, +}; + +const USAGE: &str = "Usage: vtt small_dev_shm [args...]"; + +pub fn run(args: &[String]) -> Result<()> { + let (program, command_args) = parse_command(args)?; + run_platform(program, command_args) +} + +fn parse_command(args: &[String]) -> Result<(&str, &[String])> { + args.split_first().map(|(program, args)| (program.as_str(), args)).context(USAGE) +} + +fn run_platform(program: &str, command_args: &[String]) -> Result<()> { + let uid = Uid::current().as_raw(); + let gid = Gid::current().as_raw(); + + unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS) + .context("unshare user and mount namespaces")?; + + std::fs::write("/proc/self/uid_map", format!("0 {uid} 1\n")) + .context("write /proc/self/uid_map")?; + match std::fs::write("/proc/self/setgroups", "deny") { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("write /proc/self/setgroups"), + } + std::fs::write("/proc/self/gid_map", format!("0 {gid} 1\n")) + .context("write /proc/self/gid_map")?; + + mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>) + .context("make / recursively private")?; + + mount( + Some("tmpfs"), + "/dev/shm", + Some("tmpfs"), + MsFlags::empty(), + Some("nr_blocks=1,huge=never"), + ) + .context("mount one-page tmpfs at /dev/shm")?; + + let status = Command::new(program) + .args(command_args) + .status() + .context("run command with constrained /dev/shm")?; + let code = status.code().unwrap_or_else(|| status.signal().map_or(1, |signal| 128 + signal)); + std::process::exit(code); +} diff --git a/crates/vite_task_bin/src/vtt/stat_long_filename.rs b/crates/vite_task_bin/src/vtt/stat_long_filename.rs new file mode 100644 index 000000000..1b44b1453 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/stat_long_filename.rs @@ -0,0 +1,39 @@ +use std::{error::Error, io}; + +const USAGE: &str = "Usage: vtt stat_long_filename "; + +pub fn run(args: &[String]) -> Result<(), Box> { + let count = parse_count(args)?; + access_generated_path(count, metadata)?; + Ok(()) +} + +fn parse_count(args: &[String]) -> Result { + let [count] = args else { return Err(USAGE.to_owned()) }; + count.parse().map_err(|_| USAGE.to_owned()) +} + +fn generated_path(count: usize) -> String { + "x".repeat(count) +} + +fn access_generated_path( + count: usize, + mut metadata: impl FnMut(&str) -> io::Result<()>, +) -> io::Result<()> { + let path = generated_path(count); + match metadata(&path) { + Ok(()) => Ok(()), + Err(error) + if error.kind() == io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ENAMETOOLONG) => + { + Ok(()) + } + Err(error) => Err(error), + } +} + +fn metadata(path: &str) -> io::Result<()> { + std::fs::metadata(path).map(|_| ()) +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/package.json @@ -0,0 +1 @@ +{} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml new file mode 100644 index 000000000..972e350b3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots.toml @@ -0,0 +1,8 @@ +[[e2e]] +name = "constrained_dev_shm" +comment = """ +Mounting a one-page `/dev/shm` reproduces the SIGBUS seen before fspy moved its shared-memory backing to memfd. +""" +platform = "linux-gnu" +ignore = true +steps = [["vtt", "small_dev_shm", "vt", "run", "stress"]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md new file mode 100644 index 000000000..6f3bee3ed --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/snapshots/constrained_dev_shm.md @@ -0,0 +1,11 @@ +# constrained_dev_shm + +Mounting a one-page `/dev/shm` reproduces the SIGBUS seen before fspy moved its shared-memory backing to memfd. + +## `vtt small_dev_shm vt run stress` + +**Exit code:** 135 + +``` +$ vtt stat_long_filename 1048576 +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json new file mode 100644 index 000000000..0415ae320 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "stress": { + "command": "vtt stat_long_filename 1048576", + "cache": true + } + } +}