Skip to content
Open
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** 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)).
- **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)).
- **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)).
- **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)).
Expand Down
5 changes: 4 additions & 1 deletion crates/fspy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ pub enum SpawnError {
/// so the accesses collected for the run are a subset of what it really
/// touched.
///
/// The tracking channel can be full, or a process can continue untracked
/// because the seccomp filter could not be installed.
///
/// The run itself is unaffected: recording must never stop the program
/// doing the work. What cannot be done is anything that needs every
/// access, caching above all, which has to treat the run as untracked
/// rather than as having touched only the paths that fit.
#[derive(thiserror::Error, Clone, Copy, PartialEq, Eq, Debug)]
#[error("the file-access records did not fit in the tracking channel")]
#[error("automatic file tracking could not record every file access")]
pub struct TrackingIncomplete;
156 changes: 156 additions & 0 deletions crates/fspy/tests/untracked_fallback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Tests for the untracked-exec fallback: when the preload cannot install
//! the seccomp filter, the exec must proceed untracked and the run must be
//! reported as incompletely tracked (so it is not cached), rather than
//! every spawn failing. Skipped on musl: no preload library exists there.
#![cfg(all(target_os = "linux", not(target_env = "musl")))]

use std::{
ffi::OsStr,
fs::{self, Permissions},
os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
path::{Path, PathBuf},
process::Command,
sync::LazyLock,
};

use allocator_api2::alloc::Global;
use fspy_seccomp_unotify::payload::SeccompPayload;
use fspy_shared::ipc::{
IpcStr,
channel::{RecordsLost, channel},
};
use fspy_shared_unix::payload::{Payload, encode_payload};

/// The preload cdylib, built as a dependency of this crate.
const PRELOAD_CDYLIB: &str = env!("CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX");

const TEST_BIN_CONTENT: &[u8] = include_bytes!(env!("CARGO_BIN_FILE_FSPY_TEST_BIN"));

fn test_bin_path() -> &'static Path {
static TEST_BIN_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
let test_bin_path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("fspy-test-bin");
fs::write(&test_bin_path, TEST_BIN_CONTENT).expect("failed to write test binary");
fs::set_permissions(&test_bin_path, Permissions::from_mode(0o755))
.expect("failed to set permissions on test binary");
test_bin_path
});
TEST_BIN_PATH.as_path()
}

/// A static binary exec'd from a traced process needs the preload's inline
/// seccomp install. When that install fails (here: the payload's supervisor
/// IPC path is bogus, simulating a sandbox that denies it), the binary must
/// still run — untracked — and the channel must report the loss.
#[test]
fn static_binary_runs_untracked_when_injection_fails() {
let receiver = channel(1 << 30, Global).unwrap();
let preload_path: &IpcStr = Path::new(PRELOAD_CDYLIB).into();
let payload = Payload {
ipc_channel_conf: receiver.conf(),
preload_path,
seccomp_payload: SeccompPayload::unreachable(
b"/nonexistent/fspy-unreachable-supervisor".to_vec(),
),
};
let bump = bumpalo::Bump::new();
let encoded = encode_payload(payload, &bump);

let output = Command::new("/bin/sh")
.arg("-c")
.arg(format!("exec {} stat /hello", test_bin_path().display()))
.env_clear()
.env("LD_PRELOAD", PRELOAD_CDYLIB)
.env("FSPY_PAYLOAD", OsStr::from_bytes(encoded.encoded_string.as_ref()))
.output()
.expect("failed to spawn the shell");
assert!(
output.status.success(),
"the static binary did not run: {}",
String::from_utf8_lossy(&output.stderr)
);

let Err(RecordsLost) = receiver.close() else {
panic!("the channel did not report the untracked exec");
};
}

/// The untracked fallback must replay the interposed call as it was made,
/// not as `execve`. GNU `env` runs its command through `execvp`, so a bare
/// program name found only on `PATH` still runs when injection fails;
/// forwarding it to `execve` would fail with `ENOENT` instead.
#[test]
fn execvp_fallback_keeps_path_search() {
let receiver = channel(1 << 30, Global).unwrap();
let preload_path: &IpcStr = Path::new(PRELOAD_CDYLIB).into();
let payload = Payload {
ipc_channel_conf: receiver.conf(),
preload_path,
seccomp_payload: SeccompPayload::unreachable(
b"/nonexistent/fspy-unreachable-supervisor".to_vec(),
),
};
let bump = bumpalo::Bump::new();
let encoded = encode_payload(payload, &bump);

let test_bin = test_bin_path();
let output = Command::new("/usr/bin/env")
.arg(test_bin.file_name().unwrap())
.arg("stat")
.arg("/hello")
.env_clear()
.env("PATH", test_bin.parent().unwrap())
.env("LD_PRELOAD", PRELOAD_CDYLIB)
.env("FSPY_PAYLOAD", OsStr::from_bytes(encoded.encoded_string.as_ref()))
.current_dir("/")
.output()
.expect("failed to spawn env");
assert!(
output.status.success(),
"the execvp fallback lost its PATH search: {}",
String::from_utf8_lossy(&output.stderr)
);

let Err(RecordsLost) = receiver.close() else {
panic!("the channel did not report the untracked exec");
};
}

/// Only a failed seccomp install takes the untracked path. An exec that
/// fails the way the real exec would, here `ENOEXEC` for a script without a
/// `#!` line, keeps its errno and leaves the trace complete. `sh` then runs
/// the script itself, as it does without tracking.
#[test]
fn exec_errors_are_not_reported_as_lost() {
let script = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("fspy-script-without-shebang");
fs::write(&script, "exit 0\n").expect("failed to write the script");
fs::set_permissions(&script, Permissions::from_mode(0o755))
.expect("failed to set permissions on the script");

let receiver = channel(1 << 30, Global).unwrap();
let preload_path: &IpcStr = Path::new(PRELOAD_CDYLIB).into();
let payload = Payload {
ipc_channel_conf: receiver.conf(),
preload_path,
seccomp_payload: SeccompPayload::unreachable(
b"/nonexistent/fspy-unreachable-supervisor".to_vec(),
),
};
let bump = bumpalo::Bump::new();
let encoded = encode_payload(payload, &bump);

let output = Command::new("/bin/sh")
.arg("-c")
.arg(script.as_os_str())
.env_clear()
.env("LD_PRELOAD", PRELOAD_CDYLIB)
.env("FSPY_PAYLOAD", OsStr::from_bytes(encoded.encoded_string.as_ref()))
.output()
.expect("failed to spawn the shell");
assert!(
output.status.success(),
"the script without a shebang did not run: {}",
String::from_utf8_lossy(&output.stderr)
);

assert!(receiver.close().is_ok(), "an authentic exec error was reported as an untracked exec");
}
11 changes: 11 additions & 0 deletions crates/fspy_client_unix/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ impl<'a> Client<'a> {
ipc_sender.send(&mode.with_path(path.into()));
}

/// Marks the run's trace incomplete, so the receiver treats it as
/// untracked and the runner does not cache it. Call it before the
/// process does something that tracking cannot record, such as an exec
/// that continues untracked because [`PreExec::run`] failed. It does
/// nothing when this client has no channel sender.
pub fn report_loss(&self) {
if let Some(ipc_sender) = &self.ipc_sender {
ipc_sender.report_loss();
}
}

/// Resolves and reports an exec, returning the transformed arguments to
/// forward and what to run before forwarding them.
///
Expand Down
Loading