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 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)).
- **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)).
- **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)).
- **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)).
Expand Down
10 changes: 8 additions & 2 deletions crates/fspy/src/command.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
ffi::{OsStr, OsString},
io,
path::{Path, PathBuf},
process::Stdio,
};
Expand Down Expand Up @@ -167,13 +168,18 @@ impl Command {
///
/// # Errors
///
/// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned.
/// Returns [`SpawnError`] if program resolution fails, the tracking
/// machinery cannot be initialized (e.g. the preload library cannot be
/// materialized), or the process cannot be spawned.
pub async fn spawn(
mut self,
cancellation_token: CancellationToken,
) -> Result<TrackedChild, SpawnError> {
self.resolve_program()?;
SPY_IMPL.spawn(self, cancellation_token).await
match &*SPY_IMPL {
Ok(spy) => spy.spawn(self, cancellation_token).await,
Err(e) => Err(SpawnError::Injection(io::Error::new(e.kind(), e.to_string()))),
}
}

/// Resolve program name to full path using `PATH` and cwd.
Expand Down
4 changes: 2 additions & 2 deletions crates/fspy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ pub struct TrackedChild {
pub process_handle: std::os::windows::io::OwnedHandle,
}

pub(crate) static SPY_IMPL: LazyLock<SpyImpl> = LazyLock::new(|| {
pub(crate) static SPY_IMPL: LazyLock<io::Result<SpyImpl>> = LazyLock::new(|| {
let tmp_dir = temp_dir().join("fspy");
let _ = create_dir(&tmp_dir);
SpyImpl::init_in(&tmp_dir).expect("Failed to initialize global spy")
SpyImpl::init_in(&tmp_dir)
});
94 changes: 94 additions & 0 deletions crates/fspy/tests/untracked_fallback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! Tests for the untracked-exec fallback: when the preload cannot install
//! its injection machinery, 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");
};
}

/// A preload loaded without a payload (e.g. a leaked LD_PRELOAD in an
/// env-scrubbed sandbox) must not abort its host process: the constructor
/// degrades and every exec forwards to the original.
#[test]
fn preload_without_payload_runs_untracked() {
let output = Command::new("/bin/sh")
.arg("-c")
.arg("exec /bin/true")
.env_clear()
.env("LD_PRELOAD", PRELOAD_CDYLIB)
.output()
.expect("failed to spawn the shell");
assert!(
output.status.success(),
"the preload aborted its host process: {}",
String::from_utf8_lossy(&output.stderr)
);
}
78 changes: 64 additions & 14 deletions crates/fspy_client_unix/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,35 @@ use fspy_shared::ipc::{PathAccess, channel::Sender};
use fspy_shared_unix::{
exec::ExecResolveConfig,
payload::{EncodedPayload, decode_payload_from_env},
spawn::{PreExec, handle_exec},
spawn::{PreExec, prepare_exec, resolve_exec},
};
use raw_exec::RawExec;

/// Why [`Client::handle_exec`] failed.
#[derive(Debug)]
pub enum ExecInjectionError {
/// Program resolution failed the way the real exec would have; the errno
/// is authentic and the caller should surface it as the exec's own
/// failure (set errno and return -1, or return it from `posix_spawn`).
Resolution(nix::Error),
/// The tracing injection machinery failed after the program resolved;
/// the exec was never attempted. The caller should mark the run's trace
/// incomplete ([`Client::report_loss`]) and perform the operation
/// untracked.
Injection(nix::Error),
}

impl std::fmt::Display for ExecInjectionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Resolution(errno) => write!(f, "exec resolution failed: {errno}"),
Self::Injection(errno) => write!(f, "exec injection failed: {errno}"),
}
}
}

impl std::error::Error for ExecInjectionError {}

pub struct Client<'a> {
encoded_payload: EncodedPayload<'a>,
ipc_sender: Option<Sender>,
Expand Down Expand Up @@ -51,24 +76,26 @@ impl<'a> Client<'a> {
/// with no further ceremony — and the client never retains the
/// allocator itself (see the `Send + Sync` assertion above).
///
/// # Panics
///
/// Panics when the payload is missing, malformed, or cannot be decoded,
/// and when the channel is there but cannot be attached to (see
/// [`ChannelConf::sender`](fspy_shared::ipc::channel::ChannelConf::sender)).
/// Returns `None` when the payload is missing, malformed, or cannot be
/// decoded — e.g. a leaked `LD_PRELOAD` in an env-scrubbed sandbox. The
/// host process then runs untracked rather than dying in its preload
/// constructor. When the payload decodes but its channel cannot be
/// attached to, the client still functions with `ipc_sender: None`: it
/// reports nothing, and [`Client::report_loss`] is a no-op.
#[must_use]
pub fn from_env(
envs: impl Iterator<Item = fspy_nostd::env::Entry>,
allocator: impl Allocator + Clone + 'a,
) -> Self {
let encoded_payload = decode_payload_from_env(envs, allocator.clone()).unwrap();
) -> Option<Self> {
let encoded_payload = decode_payload_from_env(envs, allocator.clone()).ok()?;

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

Self { encoded_payload, ipc_sender }
Some(Self { encoded_payload, ipc_sender })
}

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

/// Marks the run's trace incomplete, so the receiver treats it as
/// untracked (and the runner does not cache it). Used before executing
/// something untracked, e.g. when the injection machinery failed and the
/// exec is forwarded to the OS as-is. A no-op 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 before forwarding its transformed arguments.
///
/// The callback contract: capture the real exec's own outcome (return
/// value, errno) into `R` and return it as `Ok`, even when the exec
/// itself fails. Reserve `Err` for injection machinery failures, such as
/// [`PreExec::run`] failing to install the seccomp filter — the caller
/// maps those to [`ExecInjectionError::Injection`], marks the trace
/// incomplete, and retries the operation untracked.
///
/// # Safety
///
/// `raw_exec` must contain the valid C strings and pointer arrays required
Expand All @@ -97,22 +142,27 @@ impl<'a> Client<'a> {
///
/// # Errors
///
/// Returns errors from exec resolution, platform preparation, or the
/// forwarding callback.
/// [`ExecInjectionError::Resolution`] when program resolution fails the
/// way the real exec would have; [`ExecInjectionError::Injection`] when
/// the injection machinery fails after the program resolved.
pub unsafe fn handle_exec<R>(
&self,
config: ExecResolveConfig,
raw_exec: RawExec,
allocator: impl Allocator,
f: impl FnOnce(RawExec, Option<PreExec>) -> nix::Result<R>,
) -> nix::Result<R> {
) -> Result<R, ExecInjectionError> {
// SAFETY: raw_exec contains valid pointers to C strings and
// null-terminated arrays, as provided by the caller.
let mut exec = unsafe { raw_exec.to_exec() };
let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| {
resolve_exec(&mut exec, config, |mode, path| {
self.send(mode, path);
})?;
})
.map_err(ExecInjectionError::Resolution)?;
let pre_exec = prepare_exec(&mut exec, &self.encoded_payload)
.map_err(ExecInjectionError::Injection)?;
RawExec::from_exec(exec, allocator, |raw_command| f(raw_command, pre_exec))
.map_err(ExecInjectionError::Injection)
}

/// Resolves and reports one intercepted file access.
Expand Down
28 changes: 19 additions & 9 deletions crates/fspy_preload_unix/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::sync::OnceLock;

use convert::{ToAbsolutePath, ToAccessMode};
pub use fspy_client_unix::{Client, convert, raw_exec};
pub use fspy_client_unix::{Client, ExecInjectionError, convert, raw_exec};

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

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

#[cfg(not(test))]
#[ctor::ctor(unsafe)]
fn init_client() {
// SAFETY: the ctor only reads the process environment while constructing
// the client and does not retain borrowed environment views.
let current = unsafe { fspy_nostd::env::current() }.unwrap();
// Never panic here: a panic in a preload constructor aborts the host
// process. When the environment cannot be read or carries no valid
// payload (e.g. a leaked LD_PRELOAD in an env-scrubbed sandbox), CLIENT
// stays unset and the process runs untracked: the interposed calls
// forward to the originals untouched.
static BUMP: static_cell::StaticCell<fspy_nostd_alloc::PageBump> =
static_cell::StaticCell::new();
// The attach's storage: one page-backed bump housed in a static, so
// its borrow is 'static by construction and the client comes out as
// Client<'static> with no lifetime promotion anywhere. The bump is not
// Sync, so this handle cannot be stored globally by any safe code, and
// the Send/Sync assertion on Client proves the client keeps no handle.
static BUMP: static_cell::StaticCell<fspy_nostd_alloc::PageBump> =
static_cell::StaticCell::new();
let bump: &'static fspy_nostd_alloc::PageBump = BUMP.init(fspy_nostd_alloc::page_bump());
let client = Client::from_env(current.envs(), bump);
CLIENT.set(client).unwrap();
// SAFETY: the ctor only reads the process environment while constructing
// the client and does not retain borrowed environment views.
let client = unsafe { fspy_nostd::env::current() }
.ok()
.and_then(|current| Client::from_env(current.envs(), bump));
if let Some(client) = client {
let _ = CLIENT.set(client);
}
}
27 changes: 23 additions & 4 deletions crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use libc::{c_char, c_int};
use with_argv::with_argv;

use crate::{
client::{global_client, raw_exec::RawExec},
client::{ExecInjectionError, global_client, raw_exec::RawExec},
macros::intercept,
};

Expand All @@ -32,8 +32,13 @@ fn handle_exec(
argv: *const *const libc::c_char,
envp: *const *const libc::c_char,
) -> libc::c_int {
let client =
global_client().expect("exec unexpectedly called before client initialized in ctor");
let Some(client) = global_client() else {
// The ctor left the client unset (no readable environment, or no
// valid payload): run untracked by forwarding to the real exec.
// SAFETY: prog, argv, and envp are valid pointers forwarded from the
// interposed exec function.
return unsafe { execve::original()(prog, argv, envp) };
};
// SAFETY: prog, argv, and envp are valid pointers to C strings/arrays forwarded from the interposed exec function
let result = unsafe {
client.handle_exec(
Expand All @@ -50,10 +55,24 @@ fn handle_exec(
};
match result {
Ok(ret) => ret,
Err(errno) => {
Err(ExecInjectionError::Resolution(errno)) => {
// Resolution failed the way the real exec would have; the errno
// is authentic.
errno.set();
-1
}
Err(ExecInjectionError::Injection(_)) => {
// The injection machinery failed (e.g. the seccomp filter cannot
// be installed under a restrictive sandbox). Mark the run's trace
// incomplete so it is not cached, then run untracked with the
// original arguments. The original envp still carries
// LD_PRELOAD/FSPY_PAYLOAD, so each generation independently
// attempts tracking and independently degrades.
client.report_loss();
// SAFETY: prog, argv, and envp are the interposed exec function's
// own valid arguments.
unsafe { execve::original()(prog, argv, envp) }
}
}
}

Expand Down
Loading