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), [#701](https://github.com/voidzero-dev/vite-task/pull/701)).
- **Fixed** On Windows, environment variable names used by `vp run` now match regardless of ASCII letter case. Assignments in task commands override earlier assignments and inherited variables spelled differently, and `FORCE_COLOR`, `VP_RUN_CONCURRENCY_LIMIT`, and variables requested through `@voidzero-dev/vite-task-client` are found under any spelling ([#747](https://github.com/voidzero-dev/vite-task/pull/747)).
- **Changed** A task's cache settings now go inside `cache`, e.g. `cache: { env: ["NODE_ENV"], input: ["src/**"] }`; `cache: true` is the same as `cache: {}`. `env`, `untrackedEnv`, `input`, and `output` are no longer supported at the top level of a task ([#749](https://github.com/voidzero-dev/vite-task/pull/749)).
- **Fixed** Cached tasks on macOS no longer intermittently fail with exit 2 and `oils I/O error (main): No such process` when a fast command finishes before the shell gets scheduled. The bundled shell that runs task commands is updated to Oils 0.38.0, which fixes this race ([#702](https://github.com/voidzero-dev/vite-task/issues/702), [#703](https://github.com/voidzero-dev/vite-task/pull/703)).
Expand Down
116 changes: 116 additions & 0 deletions crates/fspy/tests/untracked_fallback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! 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");
};
}

/// 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");
};
}
60 changes: 54 additions & 6 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 @@ -87,8 +112,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 +140,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
2 changes: 1 addition & 1 deletion 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 Down
Loading