Skip to content

Commit c33e646

Browse files
committed
fix(fspy): run processes untracked when injection fails
In restricted sandboxes that deny the seccomp syscall, every spawn in a cached task failed with EPERM: the preload installs a seccomp user-notification filter before exec'ing a static binary, the install was refused, and the interposed exec returned the error instead of running the program. Tracking must never break the traced process. The exec and posix_spawn hooks now mark the run's trace incomplete and execute untracked with the original arguments when injection fails. The preload constructor no longer aborts the host process on a missing or malformed payload, and vt falls back to an untracked spawn when the tracked spawn itself fails (e.g. the preload library cannot be written to a read-only temp dir). Runs with escaped processes are reported as not cached, never cached from a partial trace; declaring explicit input/output config (without auto) still caches in such environments. Fixes #700.
1 parent 669ee7e commit c33e646

17 files changed

Lines changed: 491 additions & 138 deletions

File tree

‎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** Cached tasks no longer fail to spawn child processes in restricted sandboxes (e.g. rootless bubblewrap that denies the `seccomp` syscall). When file-access tracking cannot be set up for a process, the process runs untracked instead and the run is reported as not cached ([#700](https://github.com/voidzero-dev/vite-task/issues/700)).
34
- **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)).
45
- **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)).
56
- **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)).

‎crates/fspy/src/command.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{
22
ffi::{OsStr, OsString},
3+
io,
34
path::{Path, PathBuf},
45
process::Stdio,
56
};
@@ -167,13 +168,18 @@ impl Command {
167168
///
168169
/// # Errors
169170
///
170-
/// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned.
171+
/// Returns [`SpawnError`] if program resolution fails, the tracking
172+
/// machinery cannot be initialized (e.g. the preload library cannot be
173+
/// materialized), or the process cannot be spawned.
171174
pub async fn spawn(
172175
mut self,
173176
cancellation_token: CancellationToken,
174177
) -> Result<TrackedChild, SpawnError> {
175178
self.resolve_program()?;
176-
SPY_IMPL.spawn(self, cancellation_token).await
179+
match &*SPY_IMPL {
180+
Ok(spy) => spy.spawn(self, cancellation_token).await,
181+
Err(e) => Err(SpawnError::Injection(io::Error::new(e.kind(), e.to_string()))),
182+
}
177183
}
178184

179185
/// Resolve program name to full path using `PATH` and cwd.

‎crates/fspy/src/lib.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ pub struct TrackedChild {
6060
pub process_handle: std::os::windows::io::OwnedHandle,
6161
}
6262

63-
pub(crate) static SPY_IMPL: LazyLock<SpyImpl> = LazyLock::new(|| {
63+
pub(crate) static SPY_IMPL: LazyLock<io::Result<SpyImpl>> = LazyLock::new(|| {
6464
let tmp_dir = temp_dir().join("fspy");
6565
let _ = create_dir(&tmp_dir);
66-
SpyImpl::init_in(&tmp_dir).expect("Failed to initialize global spy")
66+
SpyImpl::init_in(&tmp_dir)
6767
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//! Tests for the untracked-exec fallback: when the preload cannot install
2+
//! its injection machinery, the exec must proceed untracked and the run must
3+
//! be reported as incompletely tracked (so it is not cached), rather than
4+
//! every spawn failing. Skipped on musl: no preload library exists there.
5+
#![cfg(all(target_os = "linux", not(target_env = "musl")))]
6+
7+
use std::{
8+
ffi::OsStr,
9+
fs::{self, Permissions},
10+
os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
11+
path::{Path, PathBuf},
12+
process::Command,
13+
sync::LazyLock,
14+
};
15+
16+
use allocator_api2::alloc::Global;
17+
use fspy_seccomp_unotify::payload::SeccompPayload;
18+
use fspy_shared::ipc::{
19+
IpcStr,
20+
channel::{RecordsLost, channel},
21+
};
22+
use fspy_shared_unix::payload::{Payload, encode_payload};
23+
24+
/// The preload cdylib, built as a dependency of this crate.
25+
const PRELOAD_CDYLIB: &str = env!("CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX");
26+
27+
const TEST_BIN_CONTENT: &[u8] = include_bytes!(env!("CARGO_BIN_FILE_FSPY_TEST_BIN"));
28+
29+
fn test_bin_path() -> &'static Path {
30+
static TEST_BIN_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
31+
let test_bin_path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("fspy-test-bin");
32+
fs::write(&test_bin_path, TEST_BIN_CONTENT).expect("failed to write test binary");
33+
fs::set_permissions(&test_bin_path, Permissions::from_mode(0o755))
34+
.expect("failed to set permissions on test binary");
35+
test_bin_path
36+
});
37+
TEST_BIN_PATH.as_path()
38+
}
39+
40+
/// A static binary exec'd from a traced process needs the preload's inline
41+
/// seccomp install. When that install fails (here: the payload's supervisor
42+
/// IPC path is bogus, simulating a sandbox that denies it), the binary must
43+
/// still run — untracked — and the channel must report the loss.
44+
#[test]
45+
fn static_binary_runs_untracked_when_injection_fails() {
46+
let receiver = channel(1 << 30, Global).unwrap();
47+
let preload_path: &IpcStr = Path::new(PRELOAD_CDYLIB).into();
48+
let payload = Payload {
49+
ipc_channel_conf: receiver.conf(),
50+
preload_path,
51+
seccomp_payload: SeccompPayload::unreachable(
52+
b"/nonexistent/fspy-unreachable-supervisor".to_vec(),
53+
),
54+
};
55+
let bump = bumpalo::Bump::new();
56+
let encoded = encode_payload(payload, &bump);
57+
58+
let output = Command::new("/bin/sh")
59+
.arg("-c")
60+
.arg(format!("exec {} stat /hello", test_bin_path().display()))
61+
.env_clear()
62+
.env("LD_PRELOAD", PRELOAD_CDYLIB)
63+
.env("FSPY_PAYLOAD", OsStr::from_bytes(encoded.encoded_string.as_ref()))
64+
.output()
65+
.expect("failed to spawn the shell");
66+
assert!(
67+
output.status.success(),
68+
"the static binary did not run: {}",
69+
String::from_utf8_lossy(&output.stderr)
70+
);
71+
72+
let Err(RecordsLost) = receiver.close() else {
73+
panic!("the channel did not report the untracked exec");
74+
};
75+
}
76+
77+
/// A preload loaded without a payload (e.g. a leaked LD_PRELOAD in an
78+
/// env-scrubbed sandbox) must not abort its host process: the constructor
79+
/// degrades and every exec forwards to the original.
80+
#[test]
81+
fn preload_without_payload_runs_untracked() {
82+
let output = Command::new("/bin/sh")
83+
.arg("-c")
84+
.arg("exec /bin/true")
85+
.env_clear()
86+
.env("LD_PRELOAD", PRELOAD_CDYLIB)
87+
.output()
88+
.expect("failed to spawn the shell");
89+
assert!(
90+
output.status.success(),
91+
"the preload aborted its host process: {}",
92+
String::from_utf8_lossy(&output.stderr)
93+
);
94+
}

‎crates/fspy_client_unix/src/lib.rs‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,35 @@ use fspy_shared::ipc::{PathAccess, channel::Sender};
1616
use fspy_shared_unix::{
1717
exec::ExecResolveConfig,
1818
payload::{EncodedPayload, decode_payload_from_env},
19-
spawn::{PreExec, handle_exec},
19+
spawn::{PreExec, prepare_exec, resolve_exec},
2020
};
2121
use raw_exec::RawExec;
2222

23+
/// Why [`Client::handle_exec`] failed.
24+
#[derive(Debug)]
25+
pub enum ExecInjectionError {
26+
/// Program resolution failed the way the real exec would have; the errno
27+
/// is authentic and the caller should surface it as the exec's own
28+
/// failure (set errno and return -1, or return it from `posix_spawn`).
29+
Resolution(nix::Error),
30+
/// The tracing injection machinery failed after the program resolved;
31+
/// the exec was never attempted. The caller should mark the run's trace
32+
/// incomplete ([`Client::report_loss`]) and perform the operation
33+
/// untracked.
34+
Injection(nix::Error),
35+
}
36+
37+
impl std::fmt::Display for ExecInjectionError {
38+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39+
match self {
40+
Self::Resolution(errno) => write!(f, "exec resolution failed: {errno}"),
41+
Self::Injection(errno) => write!(f, "exec injection failed: {errno}"),
42+
}
43+
}
44+
}
45+
46+
impl std::error::Error for ExecInjectionError {}
47+
2348
pub struct Client<'a> {
2449
encoded_payload: EncodedPayload<'a>,
2550
ipc_sender: Option<Sender>,
@@ -51,24 +76,26 @@ impl<'a> Client<'a> {
5176
/// with no further ceremony — and the client never retains the
5277
/// allocator itself (see the `Send + Sync` assertion above).
5378
///
54-
/// # Panics
55-
///
56-
/// Panics when the payload is missing, malformed, or cannot be decoded,
57-
/// and when the channel is there but cannot be attached to (see
58-
/// [`ChannelConf::sender`](fspy_shared::ipc::channel::ChannelConf::sender)).
79+
/// Returns `None` when the payload is missing, malformed, or cannot be
80+
/// decoded — e.g. a leaked `LD_PRELOAD` in an env-scrubbed sandbox. The
81+
/// host process then runs untracked rather than dying in its preload
82+
/// constructor. When the payload decodes but its channel cannot be
83+
/// attached to, the client still functions with `ipc_sender: None`: it
84+
/// reports nothing, and [`Client::report_loss`] is a no-op.
85+
#[must_use]
5986
pub fn from_env(
6087
envs: impl Iterator<Item = fspy_nostd::env::Entry>,
6188
allocator: impl Allocator + Clone + 'a,
62-
) -> Self {
63-
let encoded_payload = decode_payload_from_env(envs, allocator.clone()).unwrap();
89+
) -> Option<Self> {
90+
let encoded_payload = decode_payload_from_env(envs, allocator.clone()).ok()?;
6491

6592
// `None` when the channel is already over, which happens when this
6693
// process starts after the root target exited. Nothing is said
6794
// about it: a preload library writing to the traced process's
6895
// stderr corrupts whatever that process is printing.
6996
let ipc_sender = encoded_payload.payload.ipc_channel_conf.sender(allocator);
7097

71-
Self { encoded_payload, ipc_sender }
98+
Some(Self { encoded_payload, ipc_sender })
7299
}
73100

74101
fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) {
@@ -87,8 +114,26 @@ impl<'a> Client<'a> {
87114
ipc_sender.send(&PathAccess { mode, path: path.into() });
88115
}
89116

117+
/// Marks the run's trace incomplete, so the receiver treats it as
118+
/// untracked (and the runner does not cache it). Used before executing
119+
/// something untracked, e.g. when the injection machinery failed and the
120+
/// exec is forwarded to the OS as-is. A no-op when this client has no
121+
/// channel sender.
122+
pub fn report_loss(&self) {
123+
if let Some(ipc_sender) = &self.ipc_sender {
124+
ipc_sender.report_loss();
125+
}
126+
}
127+
90128
/// Resolves and reports an exec before forwarding its transformed arguments.
91129
///
130+
/// The callback contract: capture the real exec's own outcome (return
131+
/// value, errno) into `R` and return it as `Ok`, even when the exec
132+
/// itself fails. Reserve `Err` for injection machinery failures, such as
133+
/// [`PreExec::run`] failing to install the seccomp filter — the caller
134+
/// maps those to [`ExecInjectionError::Injection`], marks the trace
135+
/// incomplete, and retries the operation untracked.
136+
///
92137
/// # Safety
93138
///
94139
/// `raw_exec` must contain the valid C strings and pointer arrays required
@@ -97,22 +142,27 @@ impl<'a> Client<'a> {
97142
///
98143
/// # Errors
99144
///
100-
/// Returns errors from exec resolution, platform preparation, or the
101-
/// forwarding callback.
145+
/// [`ExecInjectionError::Resolution`] when program resolution fails the
146+
/// way the real exec would have; [`ExecInjectionError::Injection`] when
147+
/// the injection machinery fails after the program resolved.
102148
pub unsafe fn handle_exec<R>(
103149
&self,
104150
config: ExecResolveConfig,
105151
raw_exec: RawExec,
106152
allocator: impl Allocator,
107153
f: impl FnOnce(RawExec, Option<PreExec>) -> nix::Result<R>,
108-
) -> nix::Result<R> {
154+
) -> Result<R, ExecInjectionError> {
109155
// SAFETY: raw_exec contains valid pointers to C strings and
110156
// null-terminated arrays, as provided by the caller.
111157
let mut exec = unsafe { raw_exec.to_exec() };
112-
let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| {
158+
resolve_exec(&mut exec, config, |mode, path| {
113159
self.send(mode, path);
114-
})?;
160+
})
161+
.map_err(ExecInjectionError::Resolution)?;
162+
let pre_exec = prepare_exec(&mut exec, &self.encoded_payload)
163+
.map_err(ExecInjectionError::Injection)?;
115164
RawExec::from_exec(exec, allocator, |raw_command| f(raw_command, pre_exec))
165+
.map_err(ExecInjectionError::Injection)
116166
}
117167

118168
/// Resolves and reports one intercepted file access.

‎crates/fspy_preload_unix/src/client.rs‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::sync::OnceLock;
22

33
use convert::{ToAbsolutePath, ToAccessMode};
4-
pub use fspy_client_unix::{Client, convert, raw_exec};
4+
pub use fspy_client_unix::{Client, ExecInjectionError, convert, raw_exec};
55

66
static CLIENT: OnceLock<Client<'static>> = OnceLock::new();
77

@@ -19,26 +19,36 @@ pub fn global_client() -> Option<&'static Client<'static>> {
1919
pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) {
2020
if let Some(client) = global_client() {
2121
let allocator = fspy_nostd_alloc::pooled_bump();
22+
// The interception proceeds whether or not the record could be
23+
// sent — a preload library can never panic its host process.
2224
// SAFETY: path and mode contain valid pointers/values forwarded
2325
// from the interposed function's caller.
24-
unsafe { client.try_handle_open(path, mode, allocator) }.unwrap();
26+
let _ = unsafe { client.try_handle_open(path, mode, allocator) };
2527
}
2628
}
2729

2830
#[cfg(not(test))]
2931
#[ctor::ctor(unsafe)]
3032
fn init_client() {
31-
// SAFETY: the ctor only reads the process environment while constructing
32-
// the client and does not retain borrowed environment views.
33-
let current = unsafe { fspy_nostd::env::current() }.unwrap();
33+
// Never panic here: a panic in a preload constructor aborts the host
34+
// process. When the environment cannot be read or carries no valid
35+
// payload (e.g. a leaked LD_PRELOAD in an env-scrubbed sandbox), CLIENT
36+
// stays unset and the process runs untracked: the interposed calls
37+
// forward to the originals untouched.
38+
static BUMP: static_cell::StaticCell<fspy_nostd_alloc::PageBump> =
39+
static_cell::StaticCell::new();
3440
// The attach's storage: one page-backed bump housed in a static, so
3541
// its borrow is 'static by construction and the client comes out as
3642
// Client<'static> with no lifetime promotion anywhere. The bump is not
3743
// Sync, so this handle cannot be stored globally by any safe code, and
3844
// the Send/Sync assertion on Client proves the client keeps no handle.
39-
static BUMP: static_cell::StaticCell<fspy_nostd_alloc::PageBump> =
40-
static_cell::StaticCell::new();
4145
let bump: &'static fspy_nostd_alloc::PageBump = BUMP.init(fspy_nostd_alloc::page_bump());
42-
let client = Client::from_env(current.envs(), bump);
43-
CLIENT.set(client).unwrap();
46+
// SAFETY: the ctor only reads the process environment while constructing
47+
// the client and does not retain borrowed environment views.
48+
let client = unsafe { fspy_nostd::env::current() }
49+
.ok()
50+
.and_then(|current| Client::from_env(current.envs(), bump));
51+
if let Some(client) = client {
52+
let _ = CLIENT.set(client);
53+
}
4454
}

‎crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use libc::{c_char, c_int};
66
use with_argv::with_argv;
77

88
use crate::{
9-
client::{global_client, raw_exec::RawExec},
9+
client::{ExecInjectionError, global_client, raw_exec::RawExec},
1010
macros::intercept,
1111
};
1212

@@ -32,8 +32,13 @@ fn handle_exec(
3232
argv: *const *const libc::c_char,
3333
envp: *const *const libc::c_char,
3434
) -> libc::c_int {
35-
let client =
36-
global_client().expect("exec unexpectedly called before client initialized in ctor");
35+
let Some(client) = global_client() else {
36+
// The ctor left the client unset (no readable environment, or no
37+
// valid payload): run untracked by forwarding to the real exec.
38+
// SAFETY: prog, argv, and envp are valid pointers forwarded from the
39+
// interposed exec function.
40+
return unsafe { execve::original()(prog, argv, envp) };
41+
};
3742
// SAFETY: prog, argv, and envp are valid pointers to C strings/arrays forwarded from the interposed exec function
3843
let result = unsafe {
3944
client.handle_exec(
@@ -50,10 +55,24 @@ fn handle_exec(
5055
};
5156
match result {
5257
Ok(ret) => ret,
53-
Err(errno) => {
58+
Err(ExecInjectionError::Resolution(errno)) => {
59+
// Resolution failed the way the real exec would have; the errno
60+
// is authentic.
5461
errno.set();
5562
-1
5663
}
64+
Err(ExecInjectionError::Injection(_)) => {
65+
// The injection machinery failed (e.g. the seccomp filter cannot
66+
// be installed under a restrictive sandbox). Mark the run's trace
67+
// incomplete so it is not cached, then run untracked with the
68+
// original arguments. The original envp still carries
69+
// LD_PRELOAD/FSPY_PAYLOAD, so each generation independently
70+
// attempts tracking and independently degrades.
71+
client.report_loss();
72+
// SAFETY: prog, argv, and envp are the interposed exec function's
73+
// own valid arguments.
74+
unsafe { execve::original()(prog, argv, envp) }
75+
}
5776
}
5877
}
5978

0 commit comments

Comments
 (0)