Skip to content
Merged
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
11 changes: 9 additions & 2 deletions crates/fspy_preload_unix/src/client/raw_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,17 @@ impl RawExec {
mut strs: Vec<BString>,
f: impl FnOnce(*const *const libc::c_char) -> R,
) -> R {
let mut ptr_vec = Vec::<*const libc::c_char>::with_capacity(strs.len() + 1);
// The pointer array exists only for the `f` call below, and building
// it must not go through libc malloc: exec runs in the child of
// `fork()` in multithreaded programs (`posix_spawn` forks then
// execs), where malloc's lock may be held by a thread that no longer
// exists. A per-call arena has exactly this lifetime, and hands back
// the memory when the call ends.
let arena = sigsafe::alloc::arena();
let mut ptr_vec = allocator_api2::vec::Vec::with_capacity_in(strs.len() + 1, &arena);
for s in &mut strs {
s.push(0);
ptr_vec.push(s.as_ptr().cast());
ptr_vec.push(s.as_ptr().cast::<libc::c_char>());
}
ptr_vec.push(null());
f(ptr_vec.as_ptr())
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`.
158 changes: 158 additions & 0 deletions crates/sigsafe/src/alloc/mmap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//! Page-granularity allocator backed by anonymous memory mappings.

use core::{
alloc::Layout,
ptr::{self, NonNull},
};

use allocator_api2::alloc::{AllocError, Allocator};

use crate::{
mm::{MapFlags, ProtFlags, mmap_anonymous, munmap},
param::page_size,
};

/// A stateless allocator: every allocation is a fresh anonymous mapping and
/// every deallocation an `munmap`.
///
/// # Why it is safe in signal handlers and forked children
///
/// It holds no state at all — no locks, no free lists, no thread-locals;
/// the kernel does all the bookkeeping. A signal or a `fork()` can never
/// catch it holding a lock or a half-written structure, because there is
/// nothing to hold. The mapping calls and the page-size read come from
/// [`crate::mm`] and [`crate::param`], which carry the same guarantee (see
/// their docs).
///
/// # What it accepts
///
/// The only intended caller is `bump_scope::Bump`, and `Bump` only ever
/// asks its base allocator for chunks ([single call site][site]). Chunks
/// are never zero-sized (a chunk always contains its own header), and
/// their alignment is [`max(MIN_CHUNK_ALIGN, header alignment)`][chunk-align],
/// [where `MIN_CHUNK_ALIGN` is 16][mca] — so 16 bytes in practice.
///
/// This allocator accepts more than that, because mapped memory gives the
/// extra range away for free: any non-zero size, and any alignment up to
/// the page size (mappings are always page-aligned). It refuses only two
/// kinds of request, which would each need extra code and never happen:
/// zero-sized layouts (they would need fake dangling blocks) and alignment
/// above the page size (it would need mapping extra space and trimming the
/// misaligned edges). The [`Allocator`] contract allows refusing any
/// request; refused requests get [`AllocError`].
///
/// # Cost
///
/// Every allocation takes whole pages (4 KiB at least, 16 KiB on Apple
/// silicon) and one syscall, and so does every deallocation. This
/// allocator is meant to sit below a chunk pool and bump arenas, not to
/// serve small allocations directly.
///
/// `allocate` returns the whole page-rounded block, and `bump_scope` [uses
/// the full returned length][fit], so none of the page is wasted.
///
/// [mca]: https://docs.rs/bump-scope/2.3.3/src/bump_scope/chunk/size_config.rs.html#8
/// [chunk-align]: https://docs.rs/bump-scope/2.3.3/src/bump_scope/chunk/size_config.rs.html#56-58
/// [site]: https://docs.rs/bump-scope/2.3.3/src/bump_scope/raw_bump.rs.html#865
/// [fit]: https://docs.rs/bump-scope/2.3.3/src/bump_scope/raw_bump.rs.html#873-884
#[derive(Clone, Copy, Debug, Default)]
pub struct MmapAllocator;

// SAFETY: returned blocks are non-null, page-aligned (at least
// `layout.align()` for every served layout), at least `layout.size()` bytes
// large (the returned length reports the exact mapped size), stay valid
// until deallocated, and distinct allocations never overlap.
unsafe impl Allocator for MmapAllocator {
fn allocate(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> {
let page = page_size();
// Outside the served request profile (see the type docs): refuse
// rather than carry an over-alignment or dangling-block code path.
if layout.size() == 0 || layout.align() > page {
return Err(AllocError);
}
// `checked_next_multiple_of` is total: `None` on overflow (already
// impossible — `Layout` caps sizes at `isize::MAX`) or a zero page
// size, with no power-of-two assumption to uphold.
let size = layout.size().checked_next_multiple_of(page).ok_or(AllocError)?;
// SAFETY: a fresh anonymous private mapping at no particular
// address has no memory-safety preconditions.
let ptr = unsafe {
mmap_anonymous(
ptr::null_mut(),
size,
ProtFlags::READ | ProtFlags::WRITE,
MapFlags::PRIVATE,
)
}
.map_err(|_| AllocError)?;
// Mapping results are page-aligned, which covers every served
// layout.
NonNull::new(ptr.cast::<u8>())
.map(|ptr| NonNull::slice_from_raw_parts(ptr, size))
.ok_or(AllocError)
}

fn allocate_zeroed(&self, layout: Layout) -> Result<NonNull<[u8]>, AllocError> {
// Fresh anonymous mappings are already zero-filled by the kernel.
self.allocate(layout)
}

unsafe fn deallocate(&self, ptr: NonNull<u8>, layout: Layout) {
// The contract lets callers pass any size that fits the block, i.e.
// anything in `[requested, mapped]`; every such size rounds up to
// the mapped length. Zero-sized blocks are never allocated, so no
// dangling pointers can arrive here. Rounding cannot fail for a
// layout that fits a block we mapped; leaking the region is the
// safe response if it somehow did.
let Some(size) = layout.size().checked_next_multiple_of(page_size()) else { return };
// SAFETY: caller contract — `ptr` was returned by `allocate` with a
// fitting layout and the block is no longer in use. Failure is
// impossible for a region we own.
let _ = unsafe { munmap(ptr.as_ptr().cast(), size) };
}
}

#[cfg(all(test, not(miri)))]
mod tests {
use super::*;

#[test]
fn blocks_are_aligned_zeroed_and_page_rounded() {
for (size, align) in [(1, 1), (100, 64), (4096, 4096), (5 << 20, 8)] {
let layout = Layout::from_size_align(size, align).unwrap();
let block = MmapAllocator.allocate(layout).unwrap();
assert!(block.len() >= size, "size {size}");
assert_eq!(block.len() % page_size(), 0);
assert_eq!(block.cast::<u8>().as_ptr().addr() % align, 0, "align {align}");
for i in 0..block.len() {
// SAFETY: fresh exclusive block of `block.len()` bytes.
assert_eq!(unsafe { block.cast::<u8>().as_ptr().add(i).read() }, 0);
}
// SAFETY: fresh exclusive block of at least `size` bytes.
unsafe { block.cast::<u8>().as_ptr().write_bytes(0x5A, size) };
// SAFETY: allocated above; the layout fits the block.
unsafe { MmapAllocator.deallocate(block.cast(), layout) };
}
}

#[test]
fn deallocate_accepts_any_fitting_size() {
let requested = Layout::from_size_align(100, 8).unwrap();
let block = MmapAllocator.allocate(requested).unwrap();
// Deallocate with the *returned* size instead of the requested one —
// both are within the fit range the contract allows.
let fitting = Layout::from_size_align(block.len(), 8).unwrap();
// SAFETY: allocated above; `fitting` is within the block's fit range.
unsafe { MmapAllocator.deallocate(block.cast(), fitting) };
}

#[test]
fn out_of_profile_requests_are_refused() {
// Zero-sized and over-page-aligned layouts are outside the served
// request profile and must fail cleanly, not misbehave.
let zero = Layout::from_size_align(0, 16).unwrap();
assert!(MmapAllocator.allocate(zero).is_err());
let over_aligned = Layout::from_size_align(64, 1 << 24).unwrap();
assert!(MmapAllocator.allocate(over_aligned).is_err());
}
}
Loading
Loading