Skip to content
Draft
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.

6 changes: 5 additions & 1 deletion crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ impl SpyImpl {
Ok(Self { ansi_dll_path_with_nul: ansi_dll_path_with_nul.into() })
}

#[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")]
#[expect(
clippy::unused_async,
clippy::unused_async_trait_impl,
reason = "platform implementations share an async call site"
)]
pub(crate) async fn spawn(
&self,
mut command: Command,
Expand Down
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
6 changes: 5 additions & 1 deletion crates/fspy_shm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ the receiver's exact lock-file path before it calls `fspy_shm::open`. The
receiver removes that path before dropping the mapping owner, so a sender that
starts later fails before opening shared memory.

## Backend boundary
## Platform designs

Platform-specific requirements and decisions live beside their implementations:

- [Linux backend requirements](src/linux/README.md)

At this point in the stack, `fspy_shm` delegates mapping creation and opening
to the [`shared_memory`](https://crates.io/crates/shared_memory) crate. The
Expand Down
51 changes: 51 additions & 0 deletions crates/fspy_shm/src/linux/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Linux backend requirements

At this point in the stack, Linux still uses the `shared_memory` crate's POSIX
`shm_open` backend. The constrained `/dev/shm` test records why that backend
must be replaced for fspy.

## Reproduced constraint

Linux normally backs POSIX shared-memory objects with the `/dev/shm` tmpfs
mount. Containers often give that mount a small limit independent of the
host's available memory. A mapping can be created successfully and later fault
with `SIGBUS` when a write needs a page that the mount cannot supply.

The
[`constrained_dev_shm`](../../../vite_task_bin/tests/e2e_snapshots/fixtures/constrained_dev_shm/)
fixture mounts a one-page `/dev/shm` and forces file-access tracking to write
beyond that page. The resulting failure defines the acceptance condition for
a Linux-specific backend.

## Requirements

The fspy channel reserves a large logical region so intercepted processes can
append without coordinating a resize. A replacement backend must:

- avoid the container's `/dev/shm` mount limit,
- avoid allocating the mapping's full logical size up front,
- let another process open the mapping from a serialized identifier,
- support clients that run in preload code before `main`,
- keep data writes free of per-record syscalls, and
- preserve already-open views when the owner stops accepting new views.

## Candidate designs

| Option | Constraint |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Keep POSIX `shm_open` | Retains the `/dev/shm` mount dependency reproduced by the test. |
| Memory-map a temporary file | Escapes `/dev/shm`, but adds path discovery, cleanup, and possible disk writeback. |
| Inherit an anonymous descriptor | Avoids a name, but every process must propagate the descriptor to children and across `exec`. |
| Send records through a socket or pipe | Avoids a large mapping, but adds a syscall to every recorded file access. |
| Distribute an anonymous descriptor through a broker | Keeps mapped data access syscall-free while using a string address only when opening a view. |

## Why `shared_memory` cannot remain on Linux

The `shared_memory` Unix implementation creates its object with POSIX
`shm_open`. Its API does not accept an existing anonymous descriptor or expose
a hook for distributing one, so wrapping it cannot remove the `/dev/shm`
dependency.

Fspy exposes only creation, opening, an opaque identifier, and mapped bytes.
A Linux-specific implementation can satisfy that small contract without
forking the broader persistence and ownership API in `shared_memory`.
9 changes: 5 additions & 4 deletions crates/vite_powershell/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ use vite_path::{AbsolutePath, AbsolutePathBuf};
pub const POWERSHELL_PREFIX: &[&str] =
&["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"];

/// Cached location of the `PowerShell` host. Prefers cross-platform
/// `pwsh.exe` when present, falling back to the Windows built-in
/// `powershell.exe`. Returns `None` on non-Windows or when neither host
/// is on `PATH`.
/// Cached location of the `PowerShell` host.
///
/// Prefers cross-platform `pwsh.exe` when present, falling back to the Windows
/// built-in `powershell.exe`. Returns `None` on non-Windows or when neither
/// host is on `PATH`.
///
/// Cached as `Arc<AbsolutePath>` so callers that want shared ownership
/// (e.g. `vite_task_plan`'s plan-time rewrite) can do `Arc::clone(host)`
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
Loading
Loading