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
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ multiple_crate_versions = "allow"
future_not_send = "allow"

[workspace.dependencies]
allocator-api2 = { version = "0.2", default-features = false }
artifact_profile = { path = "crates/artifact_profile" }
anstream = "1.0.0"
anyhow = "1.0.103"
Expand All @@ -51,6 +52,7 @@ bindgen = "0.72.1"
bitflags = "2.10.0"
brush-parser = "0.4.0"
bstr = { version = "1.12.0", default-features = false, features = ["alloc", "std"] }
bump-scope = { version = "2", default-features = false, features = ["allocator-api2-02"] }
bumpalo = { version = "3.17.0", features = ["collections"] }
bytemuck = { version = "1.23.0", features = ["extern_crate_alloc", "must_cast"] }
cc = "1.2.39"
Expand Down Expand Up @@ -120,13 +122,15 @@ ref-cast = "1.0.24"
regex = "1.11.3"
rusqlite = "0.39.0"
rustc-hash = "2.1.1"
rustix = { version = "1", default-features = false, features = ["mm"] }
# SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0)
seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" }
serde = "1.0.219"
serde_json = "1.0.140"
serde_norway = "0.9.42"
sha2 = "0.11.0"
shell-escape = "0.1.5"
sigsafe = { path = "crates/sigsafe" }
similar = "3.0.0"
smallvec = { version = "2.0.0-alpha.12", features = ["std"] }
snapshot_test = { path = "crates/snapshot_test" }
Expand Down
2 changes: 2 additions & 0 deletions crates/fspy_preload_unix/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ publish = false
crate-type = ["cdylib"]

[target.'cfg(unix)'.dependencies]
allocator-api2 = { workspace = true, features = ["alloc"] }
anyhow = { workspace = true }
wincode = { workspace = true }
bstr = { workspace = true, default-features = false }
Expand All @@ -16,6 +17,7 @@ fspy_shared = { workspace = true }
fspy_shared_unix = { workspace = true }
libc = { workspace = true }
nix = { workspace = true, features = ["signal", "fs", "socket", "mman", "time"] }
sigsafe = { workspace = true }

[build-dependencies]
artifact_profile = { workspace = true }
Expand Down
26 changes: 21 additions & 5 deletions crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
mod with_argv;

#[cfg(target_os = "linux")]
use std::ffi::CString;

use fspy_shared_unix::exec::ExecResolveConfig;
use libc::{c_char, c_int};
use with_argv::with_argv;
Expand Down Expand Up @@ -195,21 +192,40 @@ mod linux_only {
reason = "suppresses unused warning on *::original"
)]
let _unused = execveat::original;
// One bump arena for this intercepted call. The NUL-terminated copy
// of the resolved path made below must not go through libc malloc:
// programs exec from the child of `fork()` in multithreaded
// processes (posix_spawn does exactly that), where malloc's lock may
// be held by a thread that no longer exists.
let arena = sigsafe::alloc::arena();
// SAFETY: PathAt wraps a valid dirfd and pathname pointer from the interposed execveat call
let abs_path_result = unsafe {
PathAt(dirfd, pathname).to_absolute_path(|path| {
let Some(path) = path else {
return Ok(None);
};
Ok(Some(CString::new(&**path).unwrap()))
// The resolved path plus a NUL terminator, allocated in the
// arena so it stays valid past this callback. Interior NULs
// cannot occur: the bytes come from NUL-terminated C strings
// and fd symlink targets.
let mut abs_path =
allocator_api2::vec::Vec::with_capacity_in(path.len() + 1, &arena);
abs_path.extend_from_slice(path);
abs_path.push(0);
Ok(Some(abs_path))
})
};
let abs_path = match abs_path_result {
Ok(None) => {
// SAFETY: forwarding the original arguments to the real execveat syscall
return unsafe { execveat::original()(dirfd, pathname, argv, envp, flags) };
}
Ok(Some(path)) => path.as_ptr(),
// Borrowed out of `abs_path_result`, which lives until the end
// of the function — past the `handle_exec` below that reads the
// pointer. (The previous version built a `CString`, moved it
// into this match arm, and dropped it here — `handle_exec` then
// read freed memory.)
Ok(Some(ref path)) => path.as_ptr().cast(),
Err(errno) => {
errno.set();
return -1;
Expand Down
35 changes: 35 additions & 0 deletions crates/sigsafe/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[package]
name = "sigsafe"
edition = "2024"
license.workspace = true
publish = false

[lib]
doctest = false

[target.'cfg(unix)'.dependencies]
allocator-api2 = { workspace = true }
bump-scope = { workspace = true }
rustix = { workspace = true }

# On Linux the page size is probed from the kernel directly (see param.rs);
# rustix's `param` is only needed where sysconf is the platform interface.
[target.'cfg(all(unix, not(target_os = "linux")))'.dependencies]
rustix = { workspace = true, features = ["param"] }

# The compile-time backend check in lib.rs needs a `linux_raw`-gated rustix
# item to reference; `runtime` is the module that has one.
[target.'cfg(target_os = "linux")'.dependencies]
rustix = { workspace = true, features = ["runtime"] }

# Cross-validates the page-size probe against rustix's auxv-based answer.
[target.'cfg(target_os = "linux")'.dev-dependencies]
rustix = { workspace = true, features = ["param"] }

[target.'cfg(unix)'.dev-dependencies]
# The `alloc` feature provides `Global`, letting tests run the pool against
# the host allocator (and thus under Miri).
allocator-api2 = { workspace = true, features = ["alloc"] }

[lints]
workspace = true
60 changes: 60 additions & 0 deletions crates/sigsafe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# sigsafe

Unix syscall wrappers that are safe to call where libc is not.

## Why this crate exists

The fspy preload library injects itself into traced programs and intercepts
their libc calls (`open`, `stat`, `execve`, ...). POSIX declares those
functions async-signal-safe, so programs are allowed to call them:

- inside a signal handler,
- in the child of `fork()` of a multithreaded program,
- while the process is still starting up, before libc is fully initialized.

Most of libc is off limits in those places. `malloc` is the classic trap: a
signal can pause a thread while it holds malloc's lock, and `fork()` copies a
locked lock into a child that has no thread left to unlock it — the next
`malloc` waits forever. Interception code runs exactly there, so anything it
calls must work without libc's machinery, or the traced program can hang.

## The rules

Every function in this crate follows three rules:

1. **Syscalls only.** On Linux, nothing goes through libc — the syscall
instructions are emitted directly (rustix's raw backend). On macOS there
is no stable syscall interface, so calls go through libSystem's wrappers;
for the calls exposed here those are thin stubs with no locks and no
state.
2. **No locks, no hidden state.** Nothing a signal or a `fork()` could catch
locked or half-written. Where shared state is unavoidable it is a fixed
set of atomics, each touched by single complete operations.
3. **No global allocation.** No function touches a heap behind the caller's
back. Code that needs memory gets it from an explicit allocator —
[`alloc`](src/alloc/mod.rs) provides one built on `mmap`.

## How rule 1 is enforced on Linux

rustix can be built with a libc backend instead of raw syscalls, and anything
in the dependency graph — including crates outside this repository — can
select it (the `rustix/use-libc` feature, or
`RUSTFLAGS=--cfg=rustix_use_libc`). No build script can detect that reliably,
so [`lib.rs`](src/lib.rs) checks at compile time instead: it references
`rustix::runtime`, a module that exists only in rustix's raw-syscall build.
Selecting the libc backend makes this crate fail to compile, rather than
silently losing the guarantee.

## What's inside

Functions whose rustix implementation already meets the rules are re-exposed
as-is; being listed in a module here is what marks a call as allowed, and the
backend check above is what keeps that true.

- `mm` — anonymous memory mappings: `mmap_anonymous`, `munmap`.
- `param` — `page_size`.
- `alloc` — allocation without malloc: `alloc::arena()` gives one
intercepted call a bump arena that draws 64 KiB chunks from a process-wide
lock-free pool and returns them when the call ends. Taking or returning a
chunk is one atomic swap; when the pool is empty, chunks come straight
from the kernel through `mm`.
Loading
Loading