Skip to content

Commit 9b931ee

Browse files
committed
chore: merge the narrowed exec fallback into the preload fix
2 parents e829460 + a774200 commit 9b931ee

12 files changed

Lines changed: 102 additions & 145 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Changelog
22

33
- **Fixed** A process that inherits Vite+'s file-access tracking library without its configuration, such as in a sandbox that scrubs environment variables, no longer aborts at startup; it runs untracked instead ([#753](https://github.com/voidzero-dev/vite-task/pull/753)).
4-
- **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), [#701](https://github.com/voidzero-dev/vite-task/pull/701)).
4+
- **Fixed** Cached tasks no longer fail to spawn child processes in restricted sandboxes (e.g. rootless bubblewrap that denies the `seccomp` syscall). When the `seccomp` filter for file-access tracking cannot be installed, the process runs untracked instead and the run is reported as not cached. The not-cached message now reads "automatic file tracking couldn't record everything this task did", which fits both this case and a full tracking channel ([#700](https://github.com/voidzero-dev/vite-task/issues/700), [#701](https://github.com/voidzero-dev/vite-task/pull/701)).
55
- **Changed** Tasks that write a file they also read are now cached, instead of being reported as `not cached because it modified its inputs`. For example, `tsc --incremental` reads and rewrites `tsconfig.tsbuildinfo`: the next run is a cache hit if the file is as `tsc` left it, or if it is missing, as on a fresh clone, in which case the hit restores it ([#913](https://github.com/voidzero-dev/vite-task/pull/913)).
66
- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/<package>` link, is complete ([#885](https://github.com/voidzero-dev/vite-task/pull/885)).
77
- **Fixed** On Linux and macOS, programs using automatic file-access tracking no longer abort when they call `execve`, `execv`, `execl`, `execle`, or `posix_spawn` with an empty path; the call fails with `ENOENT`, as it does without tracking. On Linux, `execveat` with an empty path and without `AT_EMPTY_PATH` now also fails with `ENOENT`, instead of trying to run what its directory descriptor refers to ([#896](https://github.com/voidzero-dev/vite-task/pull/896)).

‎crates/fspy/src/error.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ pub enum SpawnError {
3535
/// so the accesses collected for the run are a subset of what it really
3636
/// touched.
3737
///
38+
/// The tracking channel can be full, or a process can continue untracked
39+
/// because the seccomp filter could not be installed.
40+
///
3841
/// The run itself is unaffected: recording must never stop the program
3942
/// doing the work. What cannot be done is anything that needs every
4043
/// access, caching above all, which has to treat the run as untracked
4144
/// rather than as having touched only the paths that fit.
4245
#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)]
43-
#[error("the file-access records did not fit in the tracking channel")]
46+
#[error("automatic file tracking could not record every file access")]
4447
pub struct TrackingIncomplete;

‎crates/fspy/tests/untracked_fallback.rs‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! 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
2+
//! the seccomp filter, the exec must proceed untracked and the run must be
3+
//! reported as incompletely tracked (so it is not cached), rather than
44
//! every spawn failing. Skipped on musl: no preload library exists there.
55
#![cfg(all(target_os = "linux", not(target_env = "musl")))]
66

@@ -115,6 +115,46 @@ fn execvp_fallback_keeps_path_search() {
115115
};
116116
}
117117

118+
/// Only a failed seccomp install takes the untracked path. An exec that
119+
/// fails the way the real exec would, here `ENOEXEC` for a script without a
120+
/// `#!` line, keeps its errno and leaves the trace complete. `sh` then runs
121+
/// the script itself, as it does without tracking.
122+
#[test]
123+
fn exec_errors_are_not_reported_as_lost() {
124+
let script = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("fspy-script-without-shebang");
125+
fs::write(&script, "exit 0\n").expect("failed to write the script");
126+
fs::set_permissions(&script, Permissions::from_mode(0o755))
127+
.expect("failed to set permissions on the script");
128+
129+
let receiver = channel(1 << 30, Global).unwrap();
130+
let preload_path: &IpcStr = Path::new(PRELOAD_CDYLIB).into();
131+
let payload = Payload {
132+
ipc_channel_conf: receiver.conf(),
133+
preload_path,
134+
seccomp_payload: SeccompPayload::unreachable(
135+
b"/nonexistent/fspy-unreachable-supervisor".to_vec(),
136+
),
137+
};
138+
let bump = bumpalo::Bump::new();
139+
let encoded = encode_payload(payload, &bump);
140+
141+
let output = Command::new("/bin/sh")
142+
.arg("-c")
143+
.arg(script.as_os_str())
144+
.env_clear()
145+
.env("LD_PRELOAD", PRELOAD_CDYLIB)
146+
.env("FSPY_PAYLOAD", OsStr::from_bytes(encoded.encoded_string.as_ref()))
147+
.output()
148+
.expect("failed to spawn the shell");
149+
assert!(
150+
output.status.success(),
151+
"the script without a shebang did not run: {}",
152+
String::from_utf8_lossy(&output.stderr)
153+
);
154+
155+
assert!(receiver.close().is_ok(), "an authentic exec error was reported as an untracked exec");
156+
}
157+
118158
/// A preload loaded without a payload (e.g. a leaked `LD_PRELOAD` in an
119159
/// env-scrubbed sandbox) must not abort its host process: the constructor
120160
/// degrades and every exec forwards to the original.

‎crates/fspy_client_unix/src/lib.rs‎

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -16,35 +16,10 @@ use fspy_shared::ipc::{AccessMode, channel::Sender};
1616
use fspy_shared_unix::{
1717
exec::ExecResolveConfig,
1818
payload::{EncodedPayload, decode_payload_from_env},
19-
spawn::{PreExec, prepare_exec, resolve_exec},
19+
spawn::{PreExec, handle_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-
4823
pub struct Client<'a> {
4924
encoded_payload: EncodedPayload<'a>,
5025
ipc_sender: Option<Sender>,
@@ -115,10 +90,10 @@ impl<'a> Client<'a> {
11590
}
11691

11792
/// 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.
93+
/// untracked and the runner does not cache it. Call it before the
94+
/// process does something that tracking cannot record, such as an exec
95+
/// that continues untracked because [`PreExec::run`] failed. It does
96+
/// nothing when this client has no channel sender.
12297
pub fn report_loss(&self) {
12398
if let Some(ipc_sender) = &self.ipc_sender {
12499
ipc_sender.report_loss();
@@ -128,11 +103,6 @@ impl<'a> Client<'a> {
128103
/// Resolves and reports an exec, returning the transformed arguments to
129104
/// forward and what to run before forwarding them.
130105
///
131-
/// A failure of the returned [`PreExec::run`], such as failing to install
132-
/// the seccomp filter, is an injection machinery failure too: the caller
133-
/// treats it like [`ExecInjectionError::Injection`], marks the trace
134-
/// incomplete, and retries the operation untracked.
135-
///
136106
/// # Safety
137107
///
138108
/// `raw_exec` must contain the valid C strings and pointer arrays required
@@ -141,24 +111,19 @@ impl<'a> Client<'a> {
141111
///
142112
/// # Errors
143113
///
144-
/// [`ExecInjectionError::Resolution`] when program resolution fails the
145-
/// way the real exec would have; [`ExecInjectionError::Injection`] when
146-
/// the injection machinery fails after the program resolved.
114+
/// Returns errors from exec resolution or platform preparation.
147115
pub unsafe fn handle_exec<A: Allocator>(
148116
&self,
149117
config: ExecResolveConfig,
150118
raw_exec: RawExec,
151119
allocator: &A,
152-
) -> Result<(RawExec, Option<PreExec>), ExecInjectionError> {
120+
) -> nix::Result<(RawExec, Option<PreExec>)> {
153121
// SAFETY: raw_exec contains valid pointers to C strings and
154122
// null-terminated arrays, as provided by the caller.
155123
let mut exec = unsafe { raw_exec.to_exec(allocator) };
156-
resolve_exec(&mut exec, config, |path| {
124+
let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |path| {
157125
self.send(AccessMode::Read, path);
158-
})
159-
.map_err(ExecInjectionError::Resolution)?;
160-
let pre_exec = prepare_exec(&mut exec, &self.encoded_payload)
161-
.map_err(ExecInjectionError::Injection)?;
126+
})?;
162127
Ok((RawExec::from_exec(exec), pre_exec))
163128
}
164129

‎crates/fspy_preload_unix/src/client.rs‎

Lines changed: 1 addition & 1 deletion
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, ExecInjectionError, convert, raw_exec};
4+
pub use fspy_client_unix::{Client, convert, raw_exec};
55

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

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

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use fspy_shared_unix::exec::ExecResolveConfig;
55
use libc::{c_char, c_int};
66

77
use crate::{
8-
client::{ExecInjectionError, global_client, raw_exec::RawExec},
8+
client::{global_client, raw_exec::RawExec},
99
macros::intercept,
1010
};
1111

@@ -55,8 +55,8 @@ unsafe fn collect_argv<A: Allocator>(
5555
/// `untracked` performs the interposed call as the caller made it, through
5656
/// that function's own original: `execvp` keeps its `PATH` search,
5757
/// `execveat` its `dirfd` and flags, `fexecve` its descriptor. It runs when
58-
/// this process has no tracking client or the injection machinery fails, so
59-
/// the process still execs, untracked.
58+
/// this process has no tracking client or the seccomp filter cannot be
59+
/// installed, so the process still execs, untracked.
6060
fn handle_exec(
6161
allocator: impl Allocator,
6262
config: ExecResolveConfig,
@@ -70,31 +70,25 @@ fn handle_exec(
7070
// valid payload): run untracked.
7171
return untracked();
7272
};
73-
// The injection machinery failed (e.g. the seccomp filter cannot be
74-
// installed under a restrictive sandbox). Mark the run's trace incomplete
75-
// so it is not cached, then run the interposed call untracked. Its envp
76-
// still carries LD_PRELOAD/FSPY_PAYLOAD, so each generation independently
77-
// attempts tracking and independently degrades.
78-
let run_untracked = || {
79-
client.report_loss();
80-
untracked()
81-
};
8273
// SAFETY: prog, argv, and envp are valid pointers to C strings/arrays forwarded from the interposed exec function
8374
let handled = unsafe { client.handle_exec(config, RawExec { prog, argv, envp }, &allocator) };
8475
let (raw_command, pre_exec) = match handled {
8576
Ok(handled) => handled,
86-
Err(ExecInjectionError::Resolution(errno)) => {
87-
// Resolution failed the way the real exec would have; the errno
88-
// is authentic.
77+
Err(errno) => {
8978
errno.set();
9079
return -1;
9180
}
92-
Err(ExecInjectionError::Injection(_)) => return run_untracked(),
9381
};
9482
if let Some(pre_exec) = pre_exec
9583
&& pre_exec.run().is_err()
9684
{
97-
return run_untracked();
85+
// The seccomp filter cannot be installed, for example in a sandbox
86+
// that denies the `seccomp` syscall. Mark the run's trace incomplete
87+
// so it is not cached, then run the interposed call untracked. Its
88+
// envp still carries LD_PRELOAD/FSPY_PAYLOAD, so each generation
89+
// tries tracking again on its own.
90+
client.report_loss();
91+
return untracked();
9892
}
9993
// SAFETY: raw_command's strings and arrays live in `allocator`, which outlives the call
10094
unsafe { execve::original()(raw_command.prog, raw_command.argv, raw_command.envp) }

‎crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use fspy_shared_unix::exec::ExecResolveConfig;
44
use libc::{c_char, c_int};
55

66
use crate::{
7-
client::{ExecInjectionError, global_client, raw_exec::RawExec},
7+
client::{global_client, raw_exec::RawExec},
88
macros::intercept,
99
};
1010

@@ -57,20 +57,9 @@ unsafe fn handle_posix_spawn(
5757
&arena,
5858
)
5959
};
60-
// The injection machinery failed. Mark the run's trace incomplete so it
61-
// is not cached, then spawn untracked with the original arguments.
62-
let spawn_untracked = || {
63-
client.report_loss();
64-
// SAFETY: all arguments are the interposed posix_spawn(p) function's
65-
// own valid arguments.
66-
unsafe { original(pid, file, file_actions, attrp, argv, envp) }
67-
};
6860
let (raw_command, pre_exec) = match handled {
6961
Ok(handled) => handled,
70-
// Resolution failed the way the real spawn would have; posix_spawn
71-
// returns the errno code rather than -1.
72-
Err(ExecInjectionError::Resolution(errno)) => return errno as _,
73-
Err(ExecInjectionError::Injection(_)) => return spawn_untracked(),
62+
Err(errno) => return errno as _,
7463
};
7564
let call_original = move || {
7665
// SAFETY: the caller's pointers are forwarded unchanged, and raw_command's
@@ -100,8 +89,15 @@ unsafe fn handle_posix_spawn(
10089
.join()
10190
.unwrap()
10291
})
103-
// `pre_exec.run` failed, so the spawn never happened.
104-
.unwrap_or_else(|_| spawn_untracked())
92+
.unwrap_or_else(|_| {
93+
// The seccomp filter cannot be installed, so the spawn never
94+
// happened. Mark the run's trace incomplete so it is not cached,
95+
// then spawn untracked with the caller's own arguments.
96+
client.report_loss();
97+
// SAFETY: all arguments are the interposed posix_spawn(p) function's
98+
// own valid arguments.
99+
unsafe { original(pid, file, file_actions, attrp, argv, envp) }
100+
})
105101
}
106102

107103
intercept!(posix_spawnp(64): PosixSpawnFn);

‎crates/fspy_shared_unix/src/spawn/mod.rs‎

Lines changed: 10 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -62,25 +62,27 @@ pub(crate) fn absolute<A: Allocator>(path: &Path, allocator: A) -> nix::Result<V
6262
Ok(absolute)
6363
}
6464

65-
/// Resolves the exec's program path and reports the accesses that takes.
65+
/// Handles exec command resolution and injection
6666
///
67-
/// This is the half of [`handle_exec`] whose failures mean what the real
68-
/// exec's failure would have meant (see [`Exec::resolve`]), so a caller can
69-
/// forward the errno authentically.
67+
/// Resolves the program path and prepares the command for execution with
68+
/// appropriate environment variables and hooks.
7069
///
7170
/// # Errors
7271
///
73-
/// Returns an error if program resolution fails (see [`Exec::resolve`] error
74-
/// variants, such as `ENOENT` (file not found) or `EACCES` (permission denied)).
72+
/// Returns an error if:
73+
/// - Program resolution fails (see [`Exec::resolve`] error variants, such as `ENOENT` (file not found) or `EACCES` (permission denied))
74+
/// - Environment variable operations fail (e.g., `ensure_env` may return `EINVAL` if an existing value conflicts)
75+
/// - Platform-specific errors from `os_specific::handle_exec`
7576
///
7677
/// # Panics
7778
///
7879
/// Panics if the current working directory cannot be determined when converting a relative path to absolute.
79-
pub fn resolve_exec<A: Allocator + Clone>(
80+
pub fn handle_exec<A: Allocator + Clone>(
8081
command: &mut Exec<A>,
8182
config: ExecResolveConfig,
83+
encoded_payload: &EncodedPayload,
8284
mut on_path_read: impl FnMut(&Path),
83-
) -> nix::Result<()> {
85+
) -> nix::Result<Option<PreExec>> {
8486
let allocator = command.allocator().clone();
8587
let mut on_path_read = |path: &Path| {
8688
if path.is_absolute() {
@@ -93,54 +95,10 @@ pub fn resolve_exec<A: Allocator + Clone>(
9395

9496
command.resolve(&mut on_path_read, config)?;
9597
on_path_read(Path::new(OsStr::from_bytes(&command.program)));
96-
Ok(())
97-
}
9898

99-
/// Prepares a resolved exec for tracked execution: injects the preload
100-
/// environment, or arms the seccomp filter to install before exec.
101-
///
102-
/// This is the half of [`handle_exec`] whose failures are the tracing
103-
/// machinery's own, never the exec's.
104-
///
105-
/// # Errors
106-
///
107-
/// Returns an error if environment variable operations fail (e.g.,
108-
/// `ensure_env` may return `EINVAL` if an existing value conflicts) or from
109-
/// platform-specific errors in `os_specific::handle_exec`.
110-
pub fn prepare_exec<A: Allocator + Clone>(
111-
command: &mut Exec<A>,
112-
encoded_payload: &EncodedPayload,
113-
) -> nix::Result<Option<PreExec>> {
11499
os_specific::handle_exec(command, encoded_payload)
115100
}
116101

117-
/// Handles exec command resolution and injection
118-
///
119-
/// Resolves the program path and prepares the command for execution with
120-
/// appropriate environment variables and hooks. Composed of [`resolve_exec`]
121-
/// followed by [`prepare_exec`]; call them separately to tell an authentic
122-
/// resolution failure apart from an injection-machinery failure.
123-
///
124-
/// # Errors
125-
///
126-
/// Returns an error if:
127-
/// - Program resolution fails (see [`Exec::resolve`] error variants, such as `ENOENT` (file not found) or `EACCES` (permission denied))
128-
/// - Environment variable operations fail (e.g., `ensure_env` may return `EINVAL` if an existing value conflicts)
129-
/// - Platform-specific errors from `os_specific::handle_exec`
130-
///
131-
/// # Panics
132-
///
133-
/// Panics if the current working directory cannot be determined when converting a relative path to absolute.
134-
pub fn handle_exec<A: Allocator + Clone>(
135-
command: &mut Exec<A>,
136-
config: ExecResolveConfig,
137-
encoded_payload: &EncodedPayload,
138-
on_path_read: impl FnMut(&Path),
139-
) -> nix::Result<Option<PreExec>> {
140-
resolve_exec(command, config, on_path_read)?;
141-
prepare_exec(command, encoded_payload)
142-
}
143-
144102
#[cfg(test)]
145103
mod tests {
146104
use std::{os::unix::ffi::OsStrExt, path::Path};

‎crates/vt/src/session/event.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,12 @@ pub enum CacheNotUpdatedReason {
111111
/// A runner-aware tool explicitly requested that this run not be cached
112112
/// (e.g. vite dev-server, a watch task).
113113
ToolRequested,
114-
/// A tracked process could not record a file access it went on to
115-
/// perform: the channel it reports them through had no room left, or
116-
/// none for a record that long. The accesses that did arrive are a
117-
/// subset of what the task touched, so caching from them would bake in
118-
/// inputs and outputs that are not all of them.
114+
/// A tracked process could not record everything it did. For example,
115+
/// the channel it reports file accesses through had no room left, or
116+
/// the seccomp filter could not be installed and an exec continued
117+
/// untracked. The accesses that did arrive are a subset of what the
118+
/// task touched, so caching from them would bake in inputs and outputs
119+
/// that are not all of them.
119120
#[cfg_attr(not(fspy), expect(dead_code, reason = "only fspy tracking can be incomplete"))]
120121
TrackingIncomplete,
121122
}

0 commit comments

Comments
 (0)