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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Fixed** Linux automatic file tracking now covers direct syscalls from dynamic programs and across dynamic/static process transitions, while retaining preload acceleration for supported calls.
- **Fixed** Failures while waiting for a started task process to exit no longer incorrectly say the process failed to spawn ([#515](https://github.com/voidzero-dev/vite-task/pull/515)).
- **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)).
- **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)).
Expand Down
29 changes: 19 additions & 10 deletions Cargo.lock

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

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,18 @@ fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdyli
fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" }
fspy_shared = { path = "crates/fspy_shared" }
fspy_shared_unix = { path = "crates/fspy_shared_unix" }
fspy_syscall_intercept = { path = "crates/fspy_syscall_intercept" }
futures = "0.3.31"
futures-util = "0.3.31"
globset = "0.4.18"
jsonc-parser = { version = "0.32.0", features = ["serde"] }
iced-x86 = { version = "1.21.0", default-features = false, features = [
"decoder",
"instr_info",
"std",
] }
libc = "0.2.185"
libtest-mimic = "0.8.2"
memmap2 = "0.9.7"
monostate = "1.0.2"
napi = "3"
napi-build = "2"
Expand All @@ -99,6 +104,7 @@ ouroboros = "0.18.5"
owo-colors = { version = "4.1.0", features = ["supports-colors"] }
passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false }
notify = "8.0.0"
object = { version = "0.37.3", default-features = false, features = ["elf", "read_core", "std"] }
path-clean = "1.0.1"
pathdiff = "0.2.3"
petgraph = "0.8.2"
Expand Down
2 changes: 1 addition & 1 deletion crates/fspy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ouroboros = { workspace = true }
rustc-hash = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt"] }
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt", "macros"] }
tokio-util = { workspace = true }
which = { workspace = true, features = ["tracing"] }

Expand Down
9 changes: 4 additions & 5 deletions crates/fspy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

Run a command and capture all the paths it tries to access.

## macOS/Linux (glibc) implementation
## macOS implementation

It uses `DYLD_INSERT_LIBRARIES` on macOS and `LD_PRELOAD` on Linux to inject a shared library that intercepts file system calls.
The injection process is almost identical on both platforms other than the environment variable name. The implementation is in `src/unix`.
It uses `DYLD_INSERT_LIBRARIES` to inject a shared library that intercepts file system calls.

## Linux-specific implementation for fully static binaries
## Linux implementation

For fully static binaries (such as `esbuild`), `LD_PRELOAD` does not work. In this case, `seccomp_unotify` is used to intercept direct system calls. The handler is implemented in `src/unix/syscall_handler`.
Linux installs one inherited `seccomp_unotify` filter for the complete task process tree. Dynamically linked executables also receive an `LD_PRELOAD` library that accelerates safely patchable syscall sites. Static binaries, direct syscalls, and every declined acceleration case continue through seccomp.

## Linux musl implementation

Expand Down
5 changes: 5 additions & 0 deletions crates/fspy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ pub struct ChildTermination {
pub status: ExitStatus,
/// The path accesses captured from the child process.
pub path_accesses: PathAccessIterable,
/// The error that made filesystem tracing incomplete, if any.
///
/// The target process is allowed to continue after a seccomp notification
/// decoding error, so its exit status remains available to callers.
pub tracing_error: Option<io::Error>,
}

pub struct TrackedChild {
Expand Down
33 changes: 22 additions & 11 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use fspy_shared_unix::payload::Artifacts;
use fspy_shared_unix::{
exec::ExecResolveConfig,
payload::{Payload, encode_payload},
spawn::handle_exec,
spawn::handle_root_exec,
};
use futures_util::FutureExt;
#[cfg(target_os = "linux")]
Expand All @@ -28,6 +28,11 @@ use tokio_util::sync::CancellationToken;
use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY};
use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};

// Reserved for the preload syscall gate. The payload communicates the exact post-syscall PC so
// the injected library does not need to duplicate this address.
#[cfg(target_os = "linux")]
const GATE_POST_SYSCALL_PC: u64 = 0x0000_0001_0000_0008;

#[derive(Debug)]
pub struct SpyImpl {
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -75,7 +80,8 @@ impl SpyImpl {
cancellation_token: CancellationToken,
) -> Result<TrackedChild, SpawnError> {
#[cfg(target_os = "linux")]
let supervisor = supervise::<SyscallHandler>().map_err(SpawnError::Supervisor)?;
let supervisor =
supervise::<SyscallHandler>(GATE_POST_SYSCALL_PC).map_err(SpawnError::Supervisor)?;

#[cfg(not(target_env = "musl"))]
let (ipc_channel_conf, ipc_receiver) =
Expand All @@ -99,7 +105,7 @@ impl SpyImpl {

let mut exec = command.get_exec();
let mut exec_resolve_accesses = PathAccessArena::default();
let pre_exec = handle_exec(
let pre_exec = handle_root_exec(
&mut exec,
ExecResolveConfig::search_path_enabled(None),
&encoded_payload,
Expand Down Expand Up @@ -149,13 +155,18 @@ impl SpyImpl {
let arenas = std::iter::once(exec_resolve_accesses);
// Stop the supervisor and collect path accesses from it.
#[cfg(target_os = "linux")]
let arenas = arenas.chain(
supervisor
.stop()
.await?
.into_iter()
.map(syscall_handler::SyscallHandler::into_arena),
);
let supervisor_outcome = supervisor.stop().await;
#[cfg(target_os = "linux")]
let tracing_error = supervisor_outcome.error;
#[cfg(target_os = "linux")]
let supervisor_arenas = supervisor_outcome
.handlers
.into_iter()
.map(syscall_handler::SyscallHandler::into_arena);
#[cfg(target_os = "linux")]
let arenas = arenas.chain(supervisor_arenas);
#[cfg(not(target_os = "linux"))]
let tracing_error = None;
let arenas = arenas.collect::<Vec<_>>();

// Lock the ipc channel after the child has exited.
Expand All @@ -169,7 +180,7 @@ impl SpyImpl {
ipc_receiver_lock_guard,
};

io::Result::Ok(ChildTermination { status, path_accesses })
io::Result::Ok(ChildTermination { status, path_accesses, tracing_error })
})
.map(|f| f?) // flatten JoinError and io::Result
.boxed(),
Expand Down
19 changes: 11 additions & 8 deletions crates/fspy/src/unix/syscall_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,17 @@ impl SyscallHandler {
}
path = Cow::Owned(resolved_path);
}
self.arena.add(PathAccess {
mode: match flags & libc::O_ACCMODE {
libc::O_RDWR => AccessMode::READ | AccessMode::WRITE,
libc::O_WRONLY => AccessMode::WRITE,
_ => AccessMode::READ,
},
path: path.as_os_str().into(),
});
let mut mode = match flags & libc::O_ACCMODE {
libc::O_RDWR => AccessMode::READ | AccessMode::WRITE,
libc::O_WRONLY => AccessMode::WRITE,
_ => AccessMode::READ,
};
if flags & (libc::O_CREAT | libc::O_TRUNC) != 0
|| flags & libc::O_TMPFILE == libc::O_TMPFILE
{
mode.insert(AccessMode::WRITE);
}
self.arena.add(PathAccess { mode, path: path.as_os_str().into() });
Ok(())
}

Expand Down
6 changes: 5 additions & 1 deletion crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ impl SpyImpl {
}

#[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")]
#[expect(
clippy::unused_async_trait_impl,
reason = "the platform implementations share an async call contract"
)]
pub(crate) async fn spawn(
&self,
mut command: Command,
Expand Down Expand Up @@ -164,7 +168,7 @@ impl SpyImpl {
let ipc_receiver_lock_guard = OwnedReceiverLockGuard::lock_async(receiver).await?;
let path_accesses = PathAccessIterable { ipc_receiver_lock_guard };

io::Result::Ok(ChildTermination { status, path_accesses })
io::Result::Ok(ChildTermination { status, path_accesses, tracing_error: None })
})
.map(|f| f?) // flatten JoinError and io::Result
.boxed(),
Expand Down
30 changes: 30 additions & 0 deletions crates/fspy/tests/detached_descendant.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#![cfg(target_os = "linux")]

use std::{process::Stdio, time::Duration};

use tokio::{io::AsyncReadExt as _, time::timeout};
use tokio_util::sync::CancellationToken;

#[test_log::test(tokio::test)]
async fn detached_descendant_does_not_block_collection_or_lose_syscalls() -> anyhow::Result<()> {
let mut command = fspy::Command::new("/bin/sh");
command
.arg("-c")
.arg(r"(sleep 2; if test -r /dev/null; then printf ok; else printf failed; fi) &")
.stdout(Stdio::piped());

let mut child = command.spawn(CancellationToken::new()).await?;
let mut stdout = child.stdout.take().expect("stdout should be piped");
let termination = timeout(Duration::from_secs(1), child.wait_handle)
.await
.expect("detached descendant blocked root result collection")?;
assert!(termination.status.success());

let mut descendant_status = Vec::new();
timeout(Duration::from_secs(5), stdout.read_to_end(&mut descendant_status))
.await
.expect("detached descendant did not finish its filesystem access")?;
assert_eq!(descendant_status, b"ok", "detached descendant filesystem access failed");

Ok(())
}
33 changes: 33 additions & 0 deletions crates/fspy/tests/static_executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ use std::{
fs::{self, Permissions},
os::unix::fs::PermissionsExt as _,
path::{Path, PathBuf},
process::Stdio,
sync::LazyLock,
};

use fspy::PathAccessIterable;
use fspy_shared_unix::is_dynamically_linked_to_libc;
use test_log::test;
use tokio::io::AsyncReadExt as _;

use crate::test_utils::assert_contains;

Expand Down Expand Up @@ -120,3 +122,34 @@ async fn execve() {
let accesses = track_test_bin(&["execve", "/hello"], None).await;
assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::READ);
}

#[test(tokio::test)]
async fn dynamic_static_dynamic_preserves_preload_payload() {
let tmp_dir = Path::new(env!("CARGO_TARGET_TMPDIR"));
let script_path = tmp_dir.join("fspy-dynamic-descendant.sh");
fs::write(
&script_path,
b"#!/bin/sh\nif [ -n \"$FSPY_PAYLOAD\" ] && [ -n \"$LD_PRELOAD\" ]; then\n printf fspy-env-preserved\nelse\n printf fspy-env-missing\n exit 1\nfi\n",
)
.unwrap();
fs::set_permissions(&script_path, Permissions::from_mode(0o755)).unwrap();

let mut command = fspy::Command::new("/bin/sh");
command
.arg("-c")
.arg("exec \"$1\" execve \"$2\"")
.arg("sh")
.arg(test_bin_path())
.arg(&script_path);
command.stdout(Stdio::piped());
let mut child = command.spawn(tokio_util::sync::CancellationToken::new()).await.unwrap();
let mut stdout = child.stdout.take().unwrap();
let read_stdout = async move {
let mut output = Vec::new();
stdout.read_to_end(&mut output).await.unwrap();
output
};
let (output, termination) = tokio::join!(read_stdout, child.wait_handle);
assert!(termination.unwrap().status.success());
assert_eq!(output, b"fspy-env-preserved");
}
Loading
Loading