From fe0377edb02ef19999bad709307b05ff3199c241 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 09:52:14 +0000 Subject: [PATCH 1/4] fix(fspy): append tracer to existing LD_PRELOAD/DYLD_INSERT_LIBRARIES (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user's shell had already exported LD_PRELOAD (Linux) or DYLD_INSERT_LIBRARIES (macOS), fspy's ensure_env saw the existing value and returned EINVAL, surfacing as "failed to prepare the command for injection: Invalid argument (os error 22)" and aborting the run. Replace the overwrite-only ensure_env call on the preload variables with a new append_path_env that colon-appends fspy's shim. Appending (rather than prepending) keeps the user's preload ahead of fspy in interposition order, so libc calls short-circuited by the user layer remain invisible to fspy — the tracer must record what actually executed, not what was suppressed. The existing ensure_env is kept for PAYLOAD_ENV_NAME, which is single-valued. Adds a Linux-only e2e fixture that demonstrates both properties: a cdylib test preload (crates/preload_test_lib) short-circuits any path containing "preload_test_short_circuit" and forwards everything else via RTLD_NEXT. The fixture runs with LD_PRELOAD pointing at the test preload and verifies that fspy tracks real.txt (cache miss on modification) but never sees the short-circuited file (cache hit despite modification). The path to the test cdylib is discovered at test-runtime from CARGO_CDYLIB_FILE_PRELOAD_TEST_LIB via a placeholder in snapshots.toml, so the test binary is cross-compile and transfer-friendly. --- CHANGELOG.md | 1 + Cargo.lock | 8 + crates/fspy_shared_unix/src/exec/mod.rs | 119 +++++++++++++ .../fspy_shared_unix/src/spawn/linux/mod.rs | 14 +- crates/fspy_shared_unix/src/spawn/macos.rs | 10 +- crates/preload_test_lib/Cargo.toml | 16 ++ crates/preload_test_lib/src/lib.rs | 161 ++++++++++++++++++ crates/vite_task_bin/Cargo.toml | 8 + .../preexisting_ld_preload/package.json | 4 + .../preload_test_short_circuit.txt | 1 + .../fixtures/preexisting_ld_preload/real.txt | 1 + .../preexisting_ld_preload/snapshots.toml | 76 +++++++++ .../snapshots/preexisting_ld_preload.md | 95 +++++++++++ .../preexisting_ld_preload/vite-task.json | 8 + .../vite_task_bin/tests/e2e_snapshots/main.rs | 28 ++- 15 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 crates/preload_test_lib/Cargo.toml create mode 100644 crates/preload_test_lib/src/lib.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/preload_test_short_circuit.txt create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/real.txt create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/vite-task.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f5ac6f..380ce3ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer aborts with `failed to prepare the command for injection: Invalid argument` when the user environment already has `LD_PRELOAD` (Linux) or `DYLD_INSERT_LIBRARIES` (macOS) set. The tracer shim is now appended to any existing value and placed last, so user preloads keep their symbol-interposition precedence ([#340](https://github.com/voidzero-dev/vite-task/issues/340)) - **Changed** Arguments passed after a task name (e.g. `vp run test some-filter`) are now forwarded only to that task. Tasks pulled in via `dependsOn` no longer receive them ([#324](https://github.com/voidzero-dev/vite-task/issues/324)) - **Fixed** Windows file access tracking no longer panics when a task touches malformed paths that cannot be represented as workspace-relative inputs ([#330](https://github.com/voidzero-dev/vite-task/pull/330)) - **Fixed** `vp run --cache` now supports running without a task specifier and opens the interactive task selector, matching bare `vp run` behavior ([#312](https://github.com/voidzero-dev/vite-task/pull/313)) diff --git a/Cargo.lock b/Cargo.lock index 42e7fe4b..bfa71e3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2565,6 +2565,13 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "preload_test_lib" +version = "0.0.0" +dependencies = [ + "libc", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3986,6 +3993,7 @@ dependencies = [ "libc", "libtest-mimic", "notify", + "preload_test_lib", "pty_terminal", "pty_terminal_test", "pty_terminal_test_client", diff --git a/crates/fspy_shared_unix/src/exec/mod.rs b/crates/fspy_shared_unix/src/exec/mod.rs index b76bae2e..094cd696 100644 --- a/crates/fspy_shared_unix/src/exec/mod.rs +++ b/crates/fspy_shared_unix/src/exec/mod.rs @@ -175,3 +175,122 @@ pub fn ensure_env( envs.push((name.to_owned(), Some(value.to_owned()))); Ok(()) } + +/// Ensures `value` is the trailing colon-separated entry of env var `name`. +/// +/// Used for `LD_PRELOAD` / `DYLD_INSERT_LIBRARIES`, which the dynamic loader +/// treats as colon-separated lists. Appending (rather than overwriting) +/// preserves any user-provided preload, and appending to the *end* keeps +/// fspy's shim as the last interposer so a user preload that short-circuits +/// a call (returning without forwarding to libc) stays invisible to fspy — +/// mirroring what the OS actually did. +/// +/// - Absent: inserts `(name, value)`. +/// - Present with `value` already as the last colon-separated entry: no +/// change (idempotent across nested execs within the preloaded shim). +/// - Present otherwise: rewrites to `{existing}:{value}`. If `existing` is +/// empty, sets to `value` alone to avoid a leading `:` (which glibc's +/// `ld.so` interprets as the current directory). +pub fn append_path_env( + envs: &mut Vec<(BString, Option)>, + name: impl AsRef, + value: impl AsRef, +) { + let name = name.as_ref(); + let value = value.as_ref(); + if let Some(entry) = envs.iter_mut().find(|(n, _)| n == name) { + let existing: &[u8] = entry.1.as_deref().map_or(&[][..], |v| v.as_ref()); + let value_bytes: &[u8] = value.as_ref(); + let already_last = existing == value_bytes + || (existing.len() > value_bytes.len() + && existing.ends_with(value_bytes) + && existing[existing.len() - value_bytes.len() - 1] == b':'); + if already_last { + return; + } + let mut new_value = Vec::with_capacity(existing.len() + 1 + value_bytes.len()); + if !existing.is_empty() { + new_value.extend_from_slice(existing); + new_value.push(b':'); + } + new_value.extend_from_slice(value_bytes); + entry.1 = Some(BString::from(new_value)); + } else { + envs.push((name.to_owned(), Some(value.to_owned()))); + } +} + +#[cfg(test)] +mod tests { + use bstr::BString; + + use super::append_path_env; + + fn env(envs: &[(BString, Option)], name: &[u8]) -> Option> { + envs.iter() + .find(|(n, _)| AsRef::<[u8]>::as_ref(n) == name) + .and_then(|(_, v)| v.as_ref().map(|v| AsRef::<[u8]>::as_ref(v).to_vec())) + } + + #[test] + fn inserts_when_absent() { + let mut envs: Vec<(BString, Option)> = vec![]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/a.so".to_vec())); + } + + #[test] + fn noop_when_equal() { + let mut envs = vec![(BString::from("LD_PRELOAD"), Some(BString::from("/a.so")))]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/a.so".to_vec())); + } + + #[test] + fn noop_when_value_is_last_entry() { + let mut envs = vec![(BString::from("LD_PRELOAD"), Some(BString::from("/user.so:/a.so")))]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/user.so:/a.so".to_vec())); + } + + #[test] + fn appends_with_colon_when_present_and_different() { + let mut envs = vec![(BString::from("LD_PRELOAD"), Some(BString::from("/user.so")))]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/user.so:/a.so".to_vec())); + } + + #[test] + fn sets_without_leading_colon_when_existing_is_empty() { + let mut envs = vec![(BString::from("LD_PRELOAD"), Some(BString::from("")))]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/a.so".to_vec())); + } + + #[test] + fn idempotent_on_repeat() { + let mut envs: Vec<(BString, Option)> = vec![]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/a.so".to_vec())); + } + + #[test] + fn does_not_false_match_prefix_without_preceding_colon() { + // `lib/a.so` ends with `/a.so` as bytes, but the preceding byte is + // `b` not `:`, so it must NOT be treated as already-present. + let mut envs = vec![(BString::from("LD_PRELOAD"), Some(BString::from("/lib/a.so")))]; + append_path_env(&mut envs, "LD_PRELOAD", "a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/lib/a.so:a.so".to_vec())); + } + + #[test] + fn inserts_when_present_with_none_value() { + // An env var present in the list but with `None` value (name without + // `=`) should be rewritten to `Some(value)`. + let mut envs = vec![(BString::from("LD_PRELOAD"), None)]; + append_path_env(&mut envs, "LD_PRELOAD", "/a.so"); + assert_eq!(env(&envs, b"LD_PRELOAD"), Some(b"/a.so".to_vec())); + } +} diff --git a/crates/fspy_shared_unix/src/spawn/linux/mod.rs b/crates/fspy_shared_unix/src/spawn/linux/mod.rs index 0632999d..d3197da0 100644 --- a/crates/fspy_shared_unix/src/spawn/linux/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/linux/mod.rs @@ -6,7 +6,11 @@ use fspy_seccomp_unotify::{payload::SeccompPayload, target::install_target}; use memmap2::Mmap; #[cfg(not(target_env = "musl"))] -use crate::{elf, exec::ensure_env, open_exec::open_executable}; +use crate::{ + elf, + exec::{append_path_env, ensure_env}, + open_exec::open_executable, +}; use crate::{ exec::Exec, payload::{EncodedPayload, PAYLOAD_ENV_NAME}, @@ -40,11 +44,15 @@ pub fn handle_exec( nix::Error::try_from(io_error).unwrap_or(nix::Error::UnknownErrno) })?; if elf::is_dynamically_linked_to_libc(executable_mmap)? { - ensure_env( + // Append (don't overwrite) so a user-provided LD_PRELOAD keeps + // working. fspy's shim goes last so user preloads that + // short-circuit a libc call stay invisible to fspy — what the + // OS actually executed is what we want to record. + append_path_env( &mut command.envs, LD_PRELOAD, encoded_payload.payload.preload_path.as_os_str().as_bytes(), - )?; + ); ensure_env(&mut command.envs, PAYLOAD_ENV_NAME, &encoded_payload.encoded_string)?; return Ok(None); } diff --git a/crates/fspy_shared_unix/src/spawn/macos.rs b/crates/fspy_shared_unix/src/spawn/macos.rs index a33b27c0..88bd7d0a 100644 --- a/crates/fspy_shared_unix/src/spawn/macos.rs +++ b/crates/fspy_shared_unix/src/spawn/macos.rs @@ -8,7 +8,7 @@ use std::{ use phf::{Set, phf_set}; use crate::{ - exec::{Exec, ensure_env}, + exec::{Exec, append_path_env, ensure_env}, payload::{EncodedPayload, PAYLOAD_ENV_NAME}, }; @@ -60,11 +60,15 @@ pub fn handle_exec( }; if injectable { - ensure_env( + // Append (don't overwrite) so a user-provided DYLD_INSERT_LIBRARIES + // keeps working. fspy's shim goes last so user preloads that + // short-circuit a libc call stay invisible to fspy — what the OS + // actually executed is what we want to record. + append_path_env( &mut command.envs, DYLD_INSERT_LIBRARIES, encoded_payload.payload.preload_path.as_os_str().as_bytes(), - )?; + ); ensure_env(&mut command.envs, PAYLOAD_ENV_NAME, &encoded_payload.encoded_string)?; } else { command.envs.retain(|(name, _)| { diff --git a/crates/preload_test_lib/Cargo.toml b/crates/preload_test_lib/Cargo.toml new file mode 100644 index 00000000..ced26357 --- /dev/null +++ b/crates/preload_test_lib/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "preload_test_lib" +version = "0.0.0" +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] +test = false +doctest = false + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[lints] +workspace = true diff --git a/crates/preload_test_lib/src/lib.rs b/crates/preload_test_lib/src/lib.rs new file mode 100644 index 00000000..b3be3976 --- /dev/null +++ b/crates/preload_test_lib/src/lib.rs @@ -0,0 +1,161 @@ +//! Test-only `LD_PRELOAD` library used by the `preexisting_ld_preload` e2e +//! fixture. Intercepts `open`/`openat` (and their `64` variants) to exercise +//! two behaviours fspy must tolerate when appended to a pre-existing +//! `LD_PRELOAD` list: +//! +//! 1. For paths containing the marker `preload_test_short_circuit`, the +//! call is short-circuited with `ENOENT` *without* forwarding to the +//! next preloaded library. Because fspy is appended after this library +//! in the preload list, fspy never observes the call — exactly the +//! property we want to verify. +//! 2. For every other path the call is forwarded via +//! `dlsym(RTLD_NEXT, …)`, so fspy still sees the real accesses and can +//! track them as cache inputs. +#![cfg(target_os = "linux")] +#![feature(c_variadic)] + +use std::{ + ffi::{CStr, c_char, c_int}, + sync::OnceLock, +}; + +const MARKER: &[u8] = b"preload_test_short_circuit"; + +fn should_short_circuit(path: *const c_char) -> bool { + if path.is_null() { + return false; + } + // SAFETY: callers of `open`/`openat` pass a valid NUL-terminated C string + // (or NULL, handled above). + let bytes = unsafe { CStr::from_ptr(path) }.to_bytes(); + bytes.windows(MARKER.len()).any(|w| w == MARKER) +} + +fn fail_with_enoent() -> c_int { + // SAFETY: `__errno_location` is async-signal-safe and always returns a + // valid pointer to the per-thread errno. + unsafe { *libc::__errno_location() = libc::ENOENT }; + -1 +} + +const fn has_mode_arg(flags: c_int) -> bool { + flags & libc::O_CREAT != 0 || flags & libc::O_TMPFILE != 0 +} + +type OpenFn = unsafe extern "C" fn(*const c_char, c_int, ...) -> c_int; +type OpenatFn = unsafe extern "C" fn(c_int, *const c_char, c_int, ...) -> c_int; + +fn load_next_fn(name: &CStr) -> F { + // SAFETY: `dlsym` with `RTLD_NEXT` returns either NULL or a valid + // function pointer for a symbol that must exist in libc. The cast is + // valid because the caller supplies a `F` whose layout is a function + // pointer of the corresponding libc signature. + let ptr = unsafe { libc::dlsym(libc::RTLD_NEXT, name.as_ptr()) }; + assert!(!ptr.is_null(), "dlsym RTLD_NEXT returned null"); + // SAFETY: see above. + unsafe { std::mem::transmute_copy(&ptr) } +} + +fn next_open() -> OpenFn { + static S: OnceLock = OnceLock::new(); + *S.get_or_init(|| load_next_fn(c"open")) +} +fn next_open64() -> OpenFn { + static S: OnceLock = OnceLock::new(); + *S.get_or_init(|| load_next_fn(c"open64")) +} +fn next_openat() -> OpenatFn { + static S: OnceLock = OnceLock::new(); + *S.get_or_init(|| load_next_fn(c"openat")) +} +fn next_openat64() -> OpenatFn { + static S: OnceLock = OnceLock::new(); + *S.get_or_init(|| load_next_fn(c"openat64")) +} + +/// # Safety +/// Interposer over libc `open(2)`; same contract as the real function. Must +/// only be called by the dynamic loader after installation via `LD_PRELOAD`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn open(path: *const c_char, flags: c_int, mut args: ...) -> c_int { + if should_short_circuit(path) { + return fail_with_enoent(); + } + if has_mode_arg(flags) { + // SAFETY: `O_CREAT`/`O_TMPFILE` guarantees a `mode_t` follows per + // the `open(2)` contract. + let mode: libc::mode_t = unsafe { args.arg() }; + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_open()(path, flags, mode) } + } else { + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_open()(path, flags) } + } +} + +/// # Safety +/// Interposer over libc `open64(2)`; same contract as the real function. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn open64(path: *const c_char, flags: c_int, mut args: ...) -> c_int { + if should_short_circuit(path) { + return fail_with_enoent(); + } + if has_mode_arg(flags) { + // SAFETY: `O_CREAT`/`O_TMPFILE` guarantees a `mode_t` follows per + // the `open64(2)` contract. + let mode: libc::mode_t = unsafe { args.arg() }; + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_open64()(path, flags, mode) } + } else { + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_open64()(path, flags) } + } +} + +/// # Safety +/// Interposer over libc `openat(2)`; same contract as the real function. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn openat( + dirfd: c_int, + path: *const c_char, + flags: c_int, + mut args: ... +) -> c_int { + if should_short_circuit(path) { + return fail_with_enoent(); + } + if has_mode_arg(flags) { + // SAFETY: `O_CREAT`/`O_TMPFILE` guarantees a `mode_t` follows per + // the `openat(2)` contract. + let mode: libc::mode_t = unsafe { args.arg() }; + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_openat()(dirfd, path, flags, mode) } + } else { + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_openat()(dirfd, path, flags) } + } +} + +/// # Safety +/// Interposer over libc `openat64(2)`; same contract as the real function. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn openat64( + dirfd: c_int, + path: *const c_char, + flags: c_int, + mut args: ... +) -> c_int { + if should_short_circuit(path) { + return fail_with_enoent(); + } + if has_mode_arg(flags) { + // SAFETY: `O_CREAT`/`O_TMPFILE` guarantees a `mode_t` follows per + // the `openat64(2)` contract. + let mode: libc::mode_t = unsafe { args.arg() }; + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_openat64()(dirfd, path, flags, mode) } + } else { + // SAFETY: forwarding the caller's arguments unchanged. + unsafe { next_openat64()(dirfd, path, flags) } + } +} diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 75d84ae2..b8a8b8d8 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -47,6 +47,14 @@ vec1 = { workspace = true, features = ["serde"] } vite_path = { workspace = true, features = ["absolute-redaction"] } vite_workspace = { workspace = true } +[target.'cfg(target_os = "linux")'.dev-dependencies] +# Artifact dep: the cdylib is built and its path is exposed to the e2e +# test harness via `CARGO_CDYLIB_FILE_PRELOAD_TEST_LIB` at test-runtime. +preload_test_lib = { path = "../preload_test_lib", artifact = "cdylib" } + +[package.metadata.cargo-shear] +ignored = ["preload_test_lib"] + [lints] workspace = true diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/package.json new file mode 100644 index 00000000..e30e6119 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/package.json @@ -0,0 +1,4 @@ +{ + "name": "preexisting-ld-preload", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/preload_test_short_circuit.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/preload_test_short_circuit.txt new file mode 100644 index 00000000..c107b9f5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/preload_test_short_circuit.txt @@ -0,0 +1 @@ +short-circuited content diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/real.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/real.txt new file mode 100644 index 00000000..10622902 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/real.txt @@ -0,0 +1 @@ +real content diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml new file mode 100644 index 00000000..92742f9c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml @@ -0,0 +1,76 @@ +[[e2e]] +name = "preexisting_ld_preload" +platform = "linux" +comment = """ +Reproduces #340 and verifies that fspy tolerates a user-supplied +`LD_PRELOAD` by appending its shim instead of rejecting the spawn. +Appending (not prepending) also preserves symbol-interposition order: +the user's preload runs first, so short-circuited calls remain +invisible to fspy — what the OS actually executed is what fspy records. + +`preload_test_lib` (built as a cdylib artifact dep) intercepts +`open`/`openat` and short-circuits any path containing the marker +`preload_test_short_circuit`, returning `ENOENT` without forwarding. +Every other path is forwarded via `RTLD_NEXT` so fspy still observes +the real syscall. + +The `read` task prints two files. `real.txt` goes through the full +interposer chain and is tracked as an input. `preload_test_short_circuit.txt` +is short-circuited by the user preload; fspy never sees it and does not +track it. Modifying the short-circuited file must therefore be a cache +hit; modifying the real file must be a miss. +""" +steps = [ + { argv = [ + "vt", + "run", + "read", + ], envs = [ + [ + "LD_PRELOAD", + "", + ], + ], comment = "cache miss: real.txt tracked; short-circuited file reported not found" }, + { argv = [ + "vt", + "run", + "read", + ], envs = [ + [ + "LD_PRELOAD", + "", + ], + ], comment = "cache hit" }, + { argv = [ + "vtt", + "write-file", + "preload_test_short_circuit.txt", + "modified short-circuited content\n", + ], comment = "modify the untracked (short-circuited) file" }, + { argv = [ + "vt", + "run", + "read", + ], envs = [ + [ + "LD_PRELOAD", + "", + ], + ], comment = "still cache hit: short-circuited access was never tracked" }, + { argv = [ + "vtt", + "write-file", + "real.txt", + "modified real content\n", + ], comment = "modify the tracked file" }, + { argv = [ + "vt", + "run", + "read", + ], envs = [ + [ + "LD_PRELOAD", + "", + ], + ], comment = "cache miss: tracked input changed" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md new file mode 100644 index 00000000..5d65a9ee --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md @@ -0,0 +1,95 @@ +# preexisting_ld_preload + +Reproduces #340 and verifies that fspy tolerates a user-supplied +`LD_PRELOAD` by appending its shim instead of rejecting the spawn. +Appending (not prepending) also preserves symbol-interposition order: +the user's preload runs first, so short-circuited calls remain +invisible to fspy — what the OS actually executed is what fspy records. + +`preload_test_lib` (built as a cdylib artifact dep) intercepts +`open`/`openat` and short-circuits any path containing the marker +`preload_test_short_circuit`, returning `ENOENT` without forwarding. +Every other path is forwarded via `RTLD_NEXT` so fspy still observes +the real syscall. + +The `read` task prints two files. `real.txt` goes through the full +interposer chain and is tracked as an input. `preload_test_short_circuit.txt` +is short-circuited by the user preload; fspy never sees it and does not +track it. Modifying the short-circuited file must therefore be a cache +hit; modifying the real file must be a miss. + +## `LD_PRELOAD= vt run read` + +cache miss: real.txt tracked; short-circuited file reported not found + +``` +$ vtt print-file real.txt +real content + +$ vtt print-file preload_test_short_circuit.txt +preload_test_short_circuit.txt: not found + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) +``` + +## `LD_PRELOAD= vt run read` + +cache hit + +``` +$ vtt print-file real.txt ◉ cache hit, replaying +real content + +$ vtt print-file preload_test_short_circuit.txt ◉ cache hit, replaying +preload_test_short_circuit.txt: not found + +--- +vt run: 2/2 cache hit (100%). (Run `vt run --last-details` for full details) +``` + +## `vtt write-file preload_test_short_circuit.txt 'modified short-circuited content +'` + +modify the untracked (short-circuited) file + +``` +``` + +## `LD_PRELOAD= vt run read` + +still cache hit: short-circuited access was never tracked + +``` +$ vtt print-file real.txt ◉ cache hit, replaying +real content + +$ vtt print-file preload_test_short_circuit.txt ◉ cache hit, replaying +preload_test_short_circuit.txt: not found + +--- +vt run: 2/2 cache hit (100%). (Run `vt run --last-details` for full details) +``` + +## `vtt write-file real.txt 'modified real content +'` + +modify the tracked file + +``` +``` + +## `LD_PRELOAD= vt run read` + +cache miss: tracked input changed + +``` +$ vtt print-file real.txt ○ cache miss: 'real.txt' modified, executing +modified real content + +$ vtt print-file preload_test_short_circuit.txt ◉ cache hit, replaying +preload_test_short_circuit.txt: not found + +--- +vt run: 1/2 cache hit (50%). (Run `vt run --last-details` for full details) +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/vite-task.json new file mode 100644 index 00000000..1ba079d3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "read": { + "command": "vtt print-file real.txt && vtt print-file preload_test_short_circuit.txt", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index ae8e3d17..42c72bac 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -190,7 +190,8 @@ struct E2e { #[serde(default)] pub cwd: RelativePathBuf, pub steps: Vec, - /// Optional platform filter: "unix" or "windows". If set, test only runs on that platform. + /// Optional platform filter: "unix", "linux", "macos", or "windows". + /// If set, test only runs on that platform. #[serde(default)] pub platform: Option, /// When true, the generated libtest-mimic trial is marked `#[ignore]` @@ -233,6 +234,26 @@ enum TerminationState { TimedOut, } +/// Substitutes sentinels in step env values with values only known at +/// test-run time. Currently supports ``, which +/// expands to the path of the `preload_test_lib` cdylib built via the +/// artifact dependency (Linux only — the sentinel is only used by the +/// `preload_test_lib`-gated e2e fixture). Keeps the raw sentinel in the +/// snapshot's displayed command line, so snapshots stay machine-independent. +fn resolve_env_placeholder(raw: &str) -> std::borrow::Cow<'_, OsStr> { + if raw == "" { + let path = env::var_os("CARGO_CDYLIB_FILE_PRELOAD_TEST_LIB").unwrap_or_else(|| { + panic!( + "CARGO_CDYLIB_FILE_PRELOAD_TEST_LIB not set; the e2e harness requires \ + the preload_test_lib cdylib artifact to be built by cargo" + ) + }); + std::borrow::Cow::Owned(path) + } else { + std::borrow::Cow::Borrowed(OsStr::new(raw)) + } +} + /// Append a fenced markdown block containing `body`. The opening and closing /// fences sit on their own lines, and trailing whitespace inside `body` is /// trimmed so the close fence isn't preceded by blank lines. @@ -338,7 +359,8 @@ fn run_case( cmd.env("PATHEXT", ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"); } for (k, v) in step.envs() { - cmd.env(k.as_str(), v.as_str()); + let resolved = resolve_env_placeholder(v.as_str()); + cmd.env(k.as_str(), AsRef::::as_ref(&resolved)); } cmd.cwd(e2e_stage_path.join(&e2e.cwd).as_path()); @@ -509,6 +531,8 @@ fn main() { let should_run = match platform.as_str() { "unix" => cfg!(unix), "windows" => cfg!(windows), + "linux" => cfg!(target_os = "linux"), + "macos" => cfg!(target_os = "macos"), other => panic!("Unknown platform '{}' in test '{}'", other, e2e.name), }; if !should_run { From 8e94bda8af25968ef52709106ac775d0dd5f4672 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 11:06:05 +0000 Subject: [PATCH 2/4] test(e2e): gate preexisting_ld_preload fixture to linux-gnu On musl, fspy falls back to seccomp-unotify for access tracking and strips LD_PRELOAD from spawned children (spawn/linux/mod.rs). That breaks the fixture's interposer-chain assumption: without preload_test_lib loaded, the "short-circuited" file is actually opened and seccomp records it, so modifying it becomes a cache miss instead of a hit. Add a "linux-gnu" platform filter that matches target_os = linux and target_env != musl, and switch the fixture to use it. --- .../fixtures/preexisting_ld_preload/snapshots.toml | 6 +++++- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml index 92742f9c..19b040ea 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml @@ -1,6 +1,10 @@ [[e2e]] name = "preexisting_ld_preload" -platform = "linux" +# Requires fspy's LD_PRELOAD injection path, which is only active on +# glibc-Linux. On musl fspy uses seccomp-unotify instead and strips +# LD_PRELOAD from spawned children, so the fixture's interposer-chain +# assumptions don't hold. +platform = "linux-gnu" comment = """ Reproduces #340 and verifies that fspy tolerates a user-supplied `LD_PRELOAD` by appending its shim instead of rejecting the spawn. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 42c72bac..196b82b0 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -190,8 +190,8 @@ struct E2e { #[serde(default)] pub cwd: RelativePathBuf, pub steps: Vec, - /// Optional platform filter: "unix", "linux", "macos", or "windows". - /// If set, test only runs on that platform. + /// Optional platform filter: "unix", "linux", "linux-gnu", "macos", or + /// "windows". If set, test only runs on that platform. #[serde(default)] pub platform: Option, /// When true, the generated libtest-mimic trial is marked `#[ignore]` @@ -533,6 +533,14 @@ fn main() { "windows" => cfg!(windows), "linux" => cfg!(target_os = "linux"), "macos" => cfg!(target_os = "macos"), + // fspy's LD_PRELOAD injection path is only active + // on glibc-Linux; on musl, fspy switches to + // seccomp-unotify and strips LD_PRELOAD from + // spawned children, which breaks fixtures that + // depend on interposer ordering. + "linux-gnu" => { + cfg!(target_os = "linux") && !cfg!(target_env = "musl") + } other => panic!("Unknown platform '{}' in test '{}'", other, e2e.name), }; if !should_run { From 2351cc4ead64dfd417cc9ba0ee96e81bbfe46a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 11:10:48 +0000 Subject: [PATCH 3/4] style: appease rustfmt on linux-gnu match arm --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 196b82b0..fb578a49 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -538,9 +538,7 @@ fn main() { // seccomp-unotify and strips LD_PRELOAD from // spawned children, which breaks fixtures that // depend on interposer ordering. - "linux-gnu" => { - cfg!(target_os = "linux") && !cfg!(target_env = "musl") - } + "linux-gnu" => cfg!(target_os = "linux") && !cfg!(target_env = "musl"), other => panic!("Unknown platform '{}' in test '{}'", other, e2e.name), }; if !should_run { From e11108e6bec44c0726acedaa487fcc0a6de03676 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 12:24:50 +0000 Subject: [PATCH 4/4] test(e2e): drop trailing newlines in preexisting_ld_preload fixture Addresses Copilot review feedback: the trailing \n in the write-file step argument made display_command_line() render the ## snapshot header across multiple lines. The file contents change regardless of the trailing newline, so cache invalidation is still exercised. --- .../fixtures/preexisting_ld_preload/snapshots.toml | 4 ++-- .../snapshots/preexisting_ld_preload.md | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml index 19b040ea..8ce1f667 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml @@ -49,7 +49,7 @@ steps = [ "vtt", "write-file", "preload_test_short_circuit.txt", - "modified short-circuited content\n", + "modified short-circuited content", ], comment = "modify the untracked (short-circuited) file" }, { argv = [ "vt", @@ -65,7 +65,7 @@ steps = [ "vtt", "write-file", "real.txt", - "modified real content\n", + "modified real content", ], comment = "modify the tracked file" }, { argv = [ "vt", diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md index 5d65a9ee..8c9b43be 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots/preexisting_ld_preload.md @@ -48,8 +48,7 @@ preload_test_short_circuit.txt: not found vt run: 2/2 cache hit (100%). (Run `vt run --last-details` for full details) ``` -## `vtt write-file preload_test_short_circuit.txt 'modified short-circuited content -'` +## `vtt write-file preload_test_short_circuit.txt 'modified short-circuited content'` modify the untracked (short-circuited) file @@ -71,8 +70,7 @@ preload_test_short_circuit.txt: not found vt run: 2/2 cache hit (100%). (Run `vt run --last-details` for full details) ``` -## `vtt write-file real.txt 'modified real content -'` +## `vtt write-file real.txt 'modified real content'` modify the tracked file @@ -86,7 +84,6 @@ cache miss: tracked input changed ``` $ vtt print-file real.txt ○ cache miss: 'real.txt' modified, executing modified real content - $ vtt print-file preload_test_short_circuit.txt ◉ cache hit, replaying preload_test_short_circuit.txt: not found