Skip to content

Commit 635dc91

Browse files
wan9chicodex
andcommitted
feat(fspy): add universal Linux seccomp tracing
Co-authored-by: GPT-5.6 <codex@openai.com>
1 parent cda404d commit 635dc91

43 files changed

Lines changed: 4144 additions & 164 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **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.
34
- **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)).
45
- **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)).
56
- **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)).

Cargo.lock

Lines changed: 19 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,18 @@ fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdyli
7878
fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" }
7979
fspy_shared = { path = "crates/fspy_shared" }
8080
fspy_shared_unix = { path = "crates/fspy_shared_unix" }
81+
fspy_syscall_intercept = { path = "crates/fspy_syscall_intercept" }
8182
futures = "0.3.31"
8283
futures-util = "0.3.31"
8384
globset = "0.4.18"
8485
jsonc-parser = { version = "0.32.0", features = ["serde"] }
86+
iced-x86 = { version = "1.21.0", default-features = false, features = [
87+
"decoder",
88+
"instr_info",
89+
"std",
90+
] }
8591
libc = "0.2.185"
8692
libtest-mimic = "0.8.2"
87-
memmap2 = "0.9.7"
8893
monostate = "1.0.2"
8994
napi = "3"
9095
napi-build = "2"
@@ -99,6 +104,7 @@ ouroboros = "0.18.5"
99104
owo-colors = { version = "4.1.0", features = ["supports-colors"] }
100105
passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false }
101106
notify = "8.0.0"
107+
object = { version = "0.37.3", default-features = false, features = ["elf", "read_core", "std"] }
102108
path-clean = "1.0.1"
103109
pathdiff = "0.2.3"
104110
petgraph = "0.8.2"

crates/fspy/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ouroboros = { workspace = true }
1818
rustc-hash = { workspace = true }
1919
tempfile = { workspace = true }
2020
thiserror = { workspace = true }
21-
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt"] }
21+
tokio = { workspace = true, features = ["net", "process", "io-util", "sync", "rt", "macros"] }
2222
tokio-util = { workspace = true }
2323
which = { workspace = true, features = ["tracing"] }
2424

crates/fspy/README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22

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

5-
## macOS/Linux (glibc) implementation
5+
## macOS implementation
66

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

10-
## Linux-specific implementation for fully static binaries
9+
## Linux implementation
1110

12-
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`.
11+
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.
1312

1413
## Linux musl implementation
1514

crates/fspy/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ pub struct ChildTermination {
3232
pub status: ExitStatus,
3333
/// The path accesses captured from the child process.
3434
pub path_accesses: PathAccessIterable,
35+
/// The error that made filesystem tracing incomplete, if any.
36+
///
37+
/// The target process is allowed to continue after a seccomp notification
38+
/// decoding error, so its exit status remains available to callers.
39+
pub tracing_error: Option<io::Error>,
3540
}
3641

3742
pub struct TrackedChild {

crates/fspy/src/unix/mod.rs

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use fspy_shared_unix::payload::Artifacts;
1616
use fspy_shared_unix::{
1717
exec::ExecResolveConfig,
1818
payload::{Payload, encode_payload},
19-
spawn::handle_exec,
19+
spawn::handle_root_exec,
2020
};
2121
use futures_util::FutureExt;
2222
#[cfg(target_os = "linux")]
@@ -28,6 +28,11 @@ use tokio_util::sync::CancellationToken;
2828
use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY};
2929
use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};
3030

31+
// Reserved for the preload syscall gate. The payload communicates the exact post-syscall PC so
32+
// the injected library does not need to duplicate this address.
33+
#[cfg(target_os = "linux")]
34+
const GATE_POST_SYSCALL_PC: u64 = 0x0000_0001_0000_0008;
35+
3136
#[derive(Debug)]
3237
pub struct SpyImpl {
3338
#[cfg(target_os = "macos")]
@@ -75,7 +80,8 @@ impl SpyImpl {
7580
cancellation_token: CancellationToken,
7681
) -> Result<TrackedChild, SpawnError> {
7782
#[cfg(target_os = "linux")]
78-
let supervisor = supervise::<SyscallHandler>().map_err(SpawnError::Supervisor)?;
83+
let supervisor =
84+
supervise::<SyscallHandler>(GATE_POST_SYSCALL_PC).map_err(SpawnError::Supervisor)?;
7985

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

100106
let mut exec = command.get_exec();
101107
let mut exec_resolve_accesses = PathAccessArena::default();
102-
let pre_exec = handle_exec(
108+
let pre_exec = handle_root_exec(
103109
&mut exec,
104110
ExecResolveConfig::search_path_enabled(None),
105111
&encoded_payload,
@@ -149,13 +155,18 @@ impl SpyImpl {
149155
let arenas = std::iter::once(exec_resolve_accesses);
150156
// Stop the supervisor and collect path accesses from it.
151157
#[cfg(target_os = "linux")]
152-
let arenas = arenas.chain(
153-
supervisor
154-
.stop()
155-
.await?
156-
.into_iter()
157-
.map(syscall_handler::SyscallHandler::into_arena),
158-
);
158+
let supervisor_outcome = supervisor.stop().await;
159+
#[cfg(target_os = "linux")]
160+
let tracing_error = supervisor_outcome.error;
161+
#[cfg(target_os = "linux")]
162+
let supervisor_arenas = supervisor_outcome
163+
.handlers
164+
.into_iter()
165+
.map(syscall_handler::SyscallHandler::into_arena);
166+
#[cfg(target_os = "linux")]
167+
let arenas = arenas.chain(supervisor_arenas);
168+
#[cfg(not(target_os = "linux"))]
169+
let tracing_error = None;
159170
let arenas = arenas.collect::<Vec<_>>();
160171

161172
// Lock the ipc channel after the child has exited.
@@ -169,7 +180,7 @@ impl SpyImpl {
169180
ipc_receiver_lock_guard,
170181
};
171182

172-
io::Result::Ok(ChildTermination { status, path_accesses })
183+
io::Result::Ok(ChildTermination { status, path_accesses, tracing_error })
173184
})
174185
.map(|f| f?) // flatten JoinError and io::Result
175186
.boxed(),

crates/fspy/src/unix/syscall_handler/mod.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,17 @@ impl SyscallHandler {
5757
}
5858
path = Cow::Owned(resolved_path);
5959
}
60-
self.arena.add(PathAccess {
61-
mode: match flags & libc::O_ACCMODE {
62-
libc::O_RDWR => AccessMode::READ | AccessMode::WRITE,
63-
libc::O_WRONLY => AccessMode::WRITE,
64-
_ => AccessMode::READ,
65-
},
66-
path: path.as_os_str().into(),
67-
});
60+
let mut mode = match flags & libc::O_ACCMODE {
61+
libc::O_RDWR => AccessMode::READ | AccessMode::WRITE,
62+
libc::O_WRONLY => AccessMode::WRITE,
63+
_ => AccessMode::READ,
64+
};
65+
if flags & (libc::O_CREAT | libc::O_TRUNC) != 0
66+
|| flags & libc::O_TMPFILE == libc::O_TMPFILE
67+
{
68+
mode.insert(AccessMode::WRITE);
69+
}
70+
self.arena.add(PathAccess { mode, path: path.as_os_str().into() });
6871
Ok(())
6972
}
7073

crates/fspy/src/windows/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ impl SpyImpl {
6767
}
6868

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

167-
io::Result::Ok(ChildTermination { status, path_accesses })
171+
io::Result::Ok(ChildTermination { status, path_accesses, tracing_error: None })
168172
})
169173
.map(|f| f?) // flatten JoinError and io::Result
170174
.boxed(),
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#![cfg(target_os = "linux")]
2+
3+
use std::{process::Stdio, time::Duration};
4+
5+
use tokio::{io::AsyncReadExt as _, time::timeout};
6+
use tokio_util::sync::CancellationToken;
7+
8+
#[test_log::test(tokio::test)]
9+
async fn detached_descendant_does_not_block_collection_or_lose_syscalls() -> anyhow::Result<()> {
10+
let mut command = fspy::Command::new("/bin/sh");
11+
command
12+
.arg("-c")
13+
.arg(r"(sleep 2; if test -r /dev/null; then printf ok; else printf failed; fi) &")
14+
.stdout(Stdio::piped());
15+
16+
let mut child = command.spawn(CancellationToken::new()).await?;
17+
let mut stdout = child.stdout.take().expect("stdout should be piped");
18+
let termination = timeout(Duration::from_secs(1), child.wait_handle)
19+
.await
20+
.expect("detached descendant blocked root result collection")?;
21+
assert!(termination.status.success());
22+
23+
let mut descendant_status = Vec::new();
24+
timeout(Duration::from_secs(5), stdout.read_to_end(&mut descendant_status))
25+
.await
26+
.expect("detached descendant did not finish its filesystem access")?;
27+
assert_eq!(descendant_status, b"ok", "detached descendant filesystem access failed");
28+
29+
Ok(())
30+
}

0 commit comments

Comments
 (0)