Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion crates/fspy/tests/rust_std.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ mod test_utils;

use std::{
env::current_dir,
fs::{File, OpenOptions},
fs::{self, File, OpenOptions},
process::Stdio,
};

Expand Down Expand Up @@ -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()?;
Expand Down
25 changes: 19 additions & 6 deletions crates/fspy_preload_unix/src/interceptions/linux_syscall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -23,16 +26,26 @@ unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long {
let a5 = unsafe { args.next_arg::<c_long>() };

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
Expand Down
39 changes: 39 additions & 0 deletions crates/fspy_preload_unix/src/interceptions/stat.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Comment on lines +63 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to the raw syscall when libc lacks statx

On Linux systems whose libc does not export statx (for example older glibc targets), this LD_PRELOAD library still exports statx, so code that weak-links or dlsyms statx will call this shim instead of taking its own SYS_statx fallback. Returning ENOSYS here changes those traced tasks from a working kernel statx call into a failure; please invoke the raw SYS_statx path (and record the access) when RTLD_NEXT has no statx rather than exposing a stub.

Useful? React with 👍 / 👎.

};

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) }
}
52 changes: 41 additions & 11 deletions crates/fspy_preload_unix/src/macros/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
};
Expand All @@ -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;

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/vite_task_bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 9 additions & 1 deletion crates/vite_task_bin/src/vtt/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,7 +35,7 @@ fn main() {
if args.len() < 2 {
eprintln!("Usage: vtt <subcommand> [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);
}
Expand Down Expand Up @@ -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 => {
Expand Down
58 changes: 58 additions & 0 deletions crates/vite_task_bin/src/vtt/small_dev_shm.rs
Comment thread
wan9chi marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 <command> [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);
}
39 changes: 39 additions & 0 deletions crates/vite_task_bin/src/vtt/stat_long_filename.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use std::{error::Error, io};

const USAGE: &str = "Usage: vtt stat_long_filename <count>";

pub fn run(args: &[String]) -> Result<(), Box<dyn Error>> {
let count = parse_count(args)?;
access_generated_path(count, metadata)?;
Ok(())
}

fn parse_count(args: &[String]) -> Result<usize, String> {
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) {
Comment thread
wan9chi marked this conversation as resolved.
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(|_| ())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Comment thread
wan9chi marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
wan9chi marked this conversation as resolved.
ignore = true
steps = [["vtt", "small_dev_shm", "vt", "run", "stress"]]
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"tasks": {
"stress": {
"command": "vtt stat_long_filename 1048576",
"cache": true
}
}
}
Loading