diff --git a/.github/workflows/fspy-sigsys-research.yml b/.github/workflows/fspy-sigsys-research.yml
new file mode 100644
index 000000000..aa4c8ecbd
--- /dev/null
+++ b/.github/workflows/fspy-sigsys-research.yml
@@ -0,0 +1,234 @@
+name: fspy SIGSYS research
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - '.github/workflows/fspy-sigsys-research.yml'
+ - 'crates/vite_task_bin/tests/e2e_snapshots/fixtures/vitest_browser_cache/**'
+ - 'research/ptrace-exec-prototype/**'
+ - 'research/rust-injected-runtime/**'
+ - 'research/sigsys-prototype/**'
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ native-x86:
+ name: Native x86-64
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
+
+ - uses: oxc-project/setup-rust@3d6fb132fbe7cdcb66bf8ec193911c2945369d12 # v1.0.17
+ with:
+ components: llvm-tools
+
+ - name: Build and audit the freestanding Rust handler blob
+ run: |
+ rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
+ make -C research/rust-injected-runtime check
+
+ - name: Run syscall and exec prototypes
+ run: |
+ set -euxo pipefail
+ test "$(uname -m)" = x86_64
+
+ probe_dir=$(mktemp -d)
+ cp -a research/sigsys-prototype "$probe_dir/sigsys"
+ cp -a research/ptrace-exec-prototype "$probe_dir/ptrace"
+
+ cd "$probe_dir/sigsys"
+ cc -O2 -Wall -Wextra -Werror -pthread trap_bench.c -o trap_bench
+ ./trap_bench
+ cc -O2 -Wall -Wextra -Werror unotify_bench.c -o unotify_bench
+ ./unotify_bench
+ cc -O2 -Wall -Wextra -Werror reexec_bootstrap.c -o reexec_bootstrap
+ ./reexec_bootstrap
+
+ cd "$probe_dir/ptrace"
+ make check
+ timeout 30s ./recursive_injector /bin/true
+ cc -O2 -Wall -Wextra -Werror -pthread -std=gnu11 \
+ nonleader_exec.c -o nonleader_exec
+ ./nonleader_exec /bin/true
+
+ vitest-browser-x86:
+ name: Chromium sandbox compatibility boundary
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
+
+ - uses: oxc-project/setup-node@4c588e9266bd930b6ddc34307df0659ed511d187 # v1.3.1
+
+ - name: Install Chromium system dependencies
+ run: pnpm --filter vite-task-tools exec playwright install --with-deps chromium
+
+ - name: Enable Chromium's user-namespace sandbox
+ run: |
+ set -euxo pipefail
+ sysctl kernel.unprivileged_userns_clone
+ sysctl kernel.apparmor_restrict_unprivileged_userns
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+
+ - name: Verify the CI host can launch sandboxed Chromium
+ run: |
+ set -euxo pipefail
+ sandbox_control='
+ const { chromium } = await import("playwright");
+ const browser = await chromium.launch({
+ headless: true,
+ chromiumSandbox: true,
+ });
+ const page = await browser.newPage();
+ await page.setContent("
sandbox control
");
+ await browser.close();
+ '
+ pnpm --filter vite-task-tools exec node --input-type=module \
+ --eval "$sandbox_control"
+ setpriv --no-new-privs \
+ pnpm --filter vite-task-tools exec node --input-type=module \
+ --eval "$sandbox_control"
+
+ - name: Validate Vitest browser modes through the exec bridge
+ run: |
+ set -euxo pipefail
+ test "$(uname -m)" = x86_64
+
+ make -C research/ptrace-exec-prototype recursive_injector
+ probe_dir=$(mktemp -d)
+ cp -a crates/vite_task_bin/tests/e2e_snapshots/fixtures/vitest_browser_cache \
+ "$probe_dir/default"
+ cp -a crates/vite_task_bin/tests/e2e_snapshots/fixtures/vitest_browser_cache \
+ "$probe_dir/sandboxed"
+ ln -s "$PWD/packages/tools/node_modules" "$probe_dir/node_modules"
+
+ injector="$PWD/research/ptrace-exec-prototype/recursive_injector"
+ vitest="$PWD/packages/tools/node_modules/.bin/vitest"
+
+ cd "$probe_dir/default"
+ VITEST_CHROMIUM_SANDBOX=false \
+ DEBUG=vitest:browser:playwright,pw:browser \
+ timeout 120s "$injector" "$vitest" run \
+ 2>&1 | tee "$probe_dir/default.log"
+ test -s dist/result.json
+ grep -F '"success":true' dist/result.json
+ grep -F 'chromiumSandbox: false' "$probe_dir/default.log"
+ grep -E 'bridge: injected exec .*exe=.*/(chrome|headless_shell)' \
+ "$probe_dir/default.log"
+ grep -E 'bridge: summary injected_execs=[2-9][0-9]* failed_execs=0' \
+ "$probe_dir/default.log"
+
+ cd "$probe_dir/sandboxed"
+
+ sandbox_record="$probe_dir/chromium-sandbox.txt"
+ no_sandbox_record="$probe_dir/chromium-no-sandbox.txt"
+ (
+ set +x
+ while :; do
+ for cmdline_file in /proc/[0-9]*/cmdline; do
+ test -r "$cmdline_file" || continue
+ command_line=$(tr '\0' ' ' < "$cmdline_file" 2>/dev/null || true)
+ case "$command_line" in
+ *chrome-headless-shell*) ;;
+ *) continue ;;
+ esac
+ case "$command_line" in
+ *--no-sandbox*)
+ printf '%s\n' "$command_line" > "$no_sandbox_record"
+ ;;
+ esac
+ case "$command_line" in
+ *--type=renderer*) ;;
+ *) continue ;;
+ esac
+
+ pid=${cmdline_file#/proc/}
+ pid=${pid%/cmdline}
+ filters=$(awk '/^Seccomp_filters:/ { print $2 }' \
+ "/proc/$pid/status" 2>/dev/null || true)
+ if test "${filters:-0}" -ge 2; then
+ {
+ printf 'pid=%s\ncmdline=%s\n' "$pid" "$command_line"
+ awk '/^(NoNewPrivs|Seccomp|Seccomp_filters):/ { print }' \
+ "/proc/$pid/status" 2>/dev/null || true
+ } > "$sandbox_record"
+ exit 0
+ fi
+ done
+ sleep 0.02
+ done
+ ) &
+ observer_pid=$!
+
+ set +e
+ VITEST_CHROMIUM_SANDBOX=true \
+ DEBUG=vitest:browser:playwright,pw:browser \
+ timeout 120s "$injector" "$vitest" run \
+ 2>&1 | tee "$probe_dir/bridge.log"
+ run_status=${PIPESTATUS[0]}
+ set -e
+
+ kill "$observer_pid" 2>/dev/null || true
+ wait "$observer_pid" 2>/dev/null || true
+ test "$run_status" -ne 0
+
+ test -s dist/result.json
+ grep -F 'chromiumSandbox: true' "$probe_dir/bridge.log"
+ test ! -s "$no_sandbox_record"
+ test ! -s "$sandbox_record"
+ grep -E 'bridge: exec argv .* --type=zygote ' "$probe_dir/bridge.log"
+ grep -F 'bridge: zygote fd 3=socket:' "$probe_dir/bridge.log"
+ grep -F 'fspy: recvmsg returned EOF' "$probe_dir/bridge.log"
+ grep -F 'FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:207' \
+ "$probe_dir/bridge.log"
+ grep -F 'ZygoteMain: initializing' "$probe_dir/bridge.log"
+ grep -F 'write: Broken pipe (32)' "$probe_dir/bridge.log"
+ grep -E 'bridge: summary injected_execs=[2-9][0-9]* failed_execs=0' \
+ "$probe_dir/bridge.log"
+
+ docker-x86:
+ name: Docker default seccomp
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
+
+ - name: Run under an existing filter with no-new-privileges
+ run: |
+ set -euxo pipefail
+ docker run --rm \
+ --security-opt no-new-privileges \
+ -v "$PWD/research:/src:ro" \
+ gcc:14-bookworm \
+ sh -eux -c '
+ grep Seccomp /proc/self/status
+ grep NoNewPrivs /proc/self/status
+ cat /proc/self/attr/current || true
+
+ mkdir /tmp/sigsys /tmp/ptrace
+ cp /src/sigsys-prototype/trap_bench.c /tmp/sigsys/
+ cp /src/sigsys-prototype/unotify_bench.c /tmp/sigsys/
+ cp /src/sigsys-prototype/reexec_bootstrap.c /tmp/sigsys/
+ cp /src/ptrace-exec-prototype/injector.c /tmp/ptrace/
+ cp /src/ptrace-exec-prototype/nested_trap.c /tmp/ptrace/
+ cp /src/ptrace-exec-prototype/recursive_injector.c /tmp/ptrace/
+ cp /src/ptrace-exec-prototype/target.c /tmp/ptrace/
+ cp /src/ptrace-exec-prototype/Makefile /tmp/ptrace/
+
+ cd /tmp/sigsys
+ cc -O2 -Wall -Wextra -Werror -pthread trap_bench.c -o trap_bench
+ ./trap_bench
+ cc -O2 -Wall -Wextra -Werror unotify_bench.c -o unotify_bench
+ ./unotify_bench
+ cc -O2 -Wall -Wextra -Werror reexec_bootstrap.c -o reexec_bootstrap
+ ./reexec_bootstrap
+
+ cd /tmp/ptrace
+ make check
+ timeout 30s ./recursive_injector /bin/true
+ '
diff --git a/.typos.toml b/.typos.toml
index c97dbfdec..3f17d0d6b 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -2,6 +2,9 @@
ratatui = "ratatui"
PUNICODE = "PUNICODE"
+[default.extend-identifiers]
+msg_controllen = "msg_controllen"
+
[files]
extend-exclude = [
"crates/fspy_detours_sys/detours",
diff --git a/Cargo.lock b/Cargo.lock
index 351ca9422..9fecd10e3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1206,6 +1206,7 @@ dependencies = [
"anyhow",
"bstr",
"bumpalo",
+ "cc",
"csv-async",
"ctor",
"derive_more",
@@ -1213,7 +1214,6 @@ dependencies = [
"fspy_detours_sys",
"fspy_preload_unix",
"fspy_preload_windows",
- "fspy_seccomp_unotify",
"fspy_shared",
"fspy_shared_unix",
"fspy_test_bin",
@@ -1367,7 +1367,6 @@ dependencies = [
"base64",
"bstr",
"elf",
- "fspy_seccomp_unotify",
"fspy_shared",
"memmap2",
"nix 0.31.2",
diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml
index 38b9ffb51..d23b5e1c6 100644
--- a/crates/fspy/Cargo.toml
+++ b/crates/fspy/Cargo.toml
@@ -23,7 +23,6 @@ tokio-util = { workspace = true }
which = { workspace = true, features = ["tracing"] }
[target.'cfg(target_os = "linux")'.dependencies]
-fspy_seccomp_unotify = { workspace = true, features = ["supervisor"] }
nix = { workspace = true, features = ["uio"] }
tokio = { workspace = true, features = ["bytes"] }
@@ -61,6 +60,7 @@ fspy_test_bin = { path = "../fspy_test_bin", artifact = "bin", target = "x86_64-
# builds are cheap.
[build-dependencies]
anyhow = { workspace = true }
+cc = { workspace = true }
materialized_artifact_build = { workspace = true }
flate2 = { workspace = true }
fspy_preload_unix = { workspace = true }
diff --git a/crates/fspy/build.rs b/crates/fspy/build.rs
index 90b7bbcf4..05ca6d204 100644
--- a/crates/fspy/build.rs
+++ b/crates/fspy/build.rs
@@ -146,6 +146,7 @@ fn fetch_macos_binaries(out_dir: &Path) -> anyhow::Result<()> {
fn register_preload_cdylib() -> anyhow::Result<()> {
let env_name = match env::var("CARGO_CFG_TARGET_OS").unwrap().as_str() {
"windows" => "CARGO_CDYLIB_FILE_FSPY_PRELOAD_WINDOWS",
+ "linux" => return Ok(()),
_ if env::var("CARGO_CFG_TARGET_ENV").unwrap() == "musl" => return Ok(()),
_ => "CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX",
};
@@ -157,8 +158,24 @@ fn register_preload_cdylib() -> anyhow::Result<()> {
Ok(())
}
+fn build_linux_sigsys_injector() {
+ if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux")
+ || env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64")
+ {
+ return;
+ }
+
+ println!("cargo:rerun-if-changed=src/unix/sigsys_x86_64.c");
+ cc::Build::new()
+ .file("src/unix/sigsys_x86_64.c")
+ .flag_if_supported("-std=c11")
+ .warnings(true)
+ .compile("fspy_sigsys_x86_64");
+}
+
fn main() -> anyhow::Result<()> {
println!("cargo:rerun-if-changed=build.rs");
+ build_linux_sigsys_injector();
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
fetch_macos_binaries(&out_dir).context("Failed to fetch macOS binaries")?;
register_preload_cdylib().context("Failed to register preload cdylib")?;
diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs
index 6c89414ba..dfd5647e8 100644
--- a/crates/fspy/src/lib.rs
+++ b/crates/fspy/src/lib.rs
@@ -2,7 +2,6 @@
pub mod error;
-#[cfg(not(target_env = "musl"))]
mod ipc;
#[cfg(unix)]
diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs
index f01f63b5d..f4bb86dcd 100644
--- a/crates/fspy/src/unix/mod.rs
+++ b/crates/fspy/src/unix/mod.rs
@@ -1,16 +1,16 @@
#[cfg(target_os = "linux")]
-mod syscall_handler;
+mod sigsys;
#[cfg(target_os = "macos")]
mod macos_artifacts;
+#[cfg(target_os = "linux")]
+use std::os::fd::AsRawFd as _;
use std::{io, path::Path};
-#[cfg(target_os = "linux")]
-use fspy_seccomp_unotify::supervisor::supervise;
-use fspy_shared::ipc::PathAccess;
-#[cfg(not(target_env = "musl"))]
-use fspy_shared::ipc::{NativeStr, channel::channel};
+#[cfg(target_os = "macos")]
+use fspy_shared::ipc::NativeStr;
+use fspy_shared::ipc::{PathAccess, channel::channel};
#[cfg(target_os = "macos")]
use fspy_shared_unix::payload::Artifacts;
use fspy_shared_unix::{
@@ -19,31 +19,39 @@ use fspy_shared_unix::{
spawn::handle_exec,
};
use futures_util::FutureExt;
-#[cfg(target_os = "linux")]
-use syscall_handler::SyscallHandler;
use tokio::task::spawn_blocking;
use tokio_util::sync::CancellationToken;
-#[cfg(not(target_env = "musl"))]
-use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY};
-use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};
+use crate::{
+ ChildTermination, Command, TrackedChild,
+ arena::PathAccessArena,
+ error::SpawnError,
+ ipc::{OwnedReceiverLockGuard, SHM_CAPACITY},
+};
#[derive(Debug)]
pub struct SpyImpl {
#[cfg(target_os = "macos")]
artifacts: Artifacts,
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
preload_path: Box,
}
impl SpyImpl {
- /// Initialize the fs access spy by writing the preload library on disk.
- ///
- /// On musl targets, we don't build a preload library —
- /// only seccomp-based tracking is used.
- pub fn init_in(#[cfg_attr(target_env = "musl", allow(unused))] dir: &Path) -> io::Result {
- #[cfg(not(target_env = "musl"))]
+ /// Initializes platform artifacts. Linux injects its handler at exec and
+ /// does not materialize a preload library.
+ #[cfg(target_os = "linux")]
+ #[expect(
+ clippy::unnecessary_wraps,
+ reason = "keeps initialization uniform with the fallible macOS backend"
+ )]
+ pub const fn init_in(_dir: &Path) -> io::Result {
+ Ok(Self {})
+ }
+
+ #[cfg(target_os = "macos")]
+ pub fn init_in(dir: &Path) -> io::Result {
let preload_path = {
use materialized_artifact::{Artifact, artifact};
@@ -54,7 +62,7 @@ impl SpyImpl {
};
Ok(Self {
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
preload_path,
#[cfg(target_os = "macos")]
artifacts: {
@@ -74,25 +82,21 @@ impl SpyImpl {
mut command: Command,
cancellation_token: CancellationToken,
) -> Result {
- #[cfg(target_os = "linux")]
- let supervisor = supervise::().map_err(SpawnError::Supervisor)?;
-
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
let (ipc_channel_conf, ipc_receiver) =
channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?;
+ #[cfg(target_os = "linux")]
+ let (_, ipc_receiver) = channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?;
let payload = Payload {
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
ipc_channel_conf,
#[cfg(target_os = "macos")]
artifacts: self.artifacts.clone(),
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
preload_path: self.preload_path.clone(),
-
- #[cfg(target_os = "linux")]
- seccomp_payload: supervisor.payload().clone(),
};
let encoded_payload = encode_payload(payload);
@@ -108,14 +112,25 @@ impl SpyImpl {
},
)
.map_err(|err| SpawnError::Injection(err.into()))?;
+ #[cfg(target_os = "linux")]
+ debug_assert!(pre_exec.is_none());
command.set_exec(exec);
command.env("FSPY", "1");
let mut tokio_command = command.into_tokio_command();
- // SAFETY: the pre_exec closure only calls pre_exec.run() which is safe to call in a fork context
+ #[cfg(target_os = "linux")]
+ let shm_fd = ipc_receiver.shm_fd().as_raw_fd();
+ #[cfg(target_os = "linux")]
+ let shm_len = ipc_receiver.shm_len();
+
+ // SAFETY: both platform hooks are restricted to async-signal-safe raw
+ // operations in the post-fork child.
unsafe {
tokio_command.pre_exec(move || {
+ #[cfg(target_os = "linux")]
+ sigsys::prepare(shm_fd)?;
+ #[cfg(target_os = "macos")]
if let Some(pre_exec) = pre_exec.as_ref() {
pre_exec.run()?;
}
@@ -123,13 +138,25 @@ impl SpyImpl {
});
}
- // tokio_command.spawn blocks while executing the `pre_exec` closure.
- // Run it inside spawn_blocking to avoid blocking the tokio runtime, especially the supervisor loop,
- // which needs to accept incoming connections while `pre_exec` is connecting to it.
- let mut child = spawn_blocking(move || tokio_command.spawn())
- .await
- .map_err(|err| SpawnError::OsSpawn(err.into()))?
- .map_err(SpawnError::OsSpawn)?;
+ // Spawn and the post-exec ptrace handshake are blocking operations.
+ let mut child = spawn_blocking(move || {
+ let child = tokio_command.spawn().map_err(SpawnError::OsSpawn)?;
+ #[cfg(target_os = "linux")]
+ let child = {
+ let mut child = child;
+ let pid = child.id().ok_or_else(|| {
+ SpawnError::Injection(io::Error::other("spawned child has no process id"))
+ })?;
+ if let Err(error) = sigsys::inject(pid, shm_fd, shm_len) {
+ let _ = child.start_kill();
+ return Err(SpawnError::Injection(error));
+ }
+ child
+ };
+ Ok(child)
+ })
+ .await
+ .map_err(|err| SpawnError::OsSpawn(err.into()))??;
Ok(TrackedChild {
stdin: child.stdin.take(),
@@ -146,28 +173,13 @@ impl SpyImpl {
}
};
- let arenas = std::iter::once(exec_resolve_accesses);
- // Stop the supervisor and collect path accesses from it.
- #[cfg(target_os = "linux")]
- let arenas = arenas.chain(
- supervisor
- .stop()
- .await?
- .into_iter()
- .map(syscall_handler::SyscallHandler::into_arena),
- );
- let arenas = arenas.collect::>();
+ let arenas = vec![exec_resolve_accesses];
// Lock the ipc channel after the child has exited.
// We are not interested in path accesses from descendants after the main child has exited.
- #[cfg(not(target_env = "musl"))]
let ipc_receiver_lock_guard =
OwnedReceiverLockGuard::lock_async(ipc_receiver).await?;
- let path_accesses = PathAccessIterable {
- arenas,
- #[cfg(not(target_env = "musl"))]
- ipc_receiver_lock_guard,
- };
+ let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard };
io::Result::Ok(ChildTermination { status, path_accesses })
})
@@ -179,7 +191,6 @@ impl SpyImpl {
pub struct PathAccessIterable {
arenas: Vec,
- #[cfg(not(target_env = "musl"))]
ipc_receiver_lock_guard: OwnedReceiverLockGuard,
}
@@ -188,14 +199,7 @@ impl PathAccessIterable {
let accesses_in_arena =
self.arenas.iter().flat_map(|arena| arena.borrow_accesses().iter()).copied();
- #[cfg(not(target_env = "musl"))]
- {
- let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses();
- accesses_in_shm.chain(accesses_in_arena)
- }
- #[cfg(target_env = "musl")]
- {
- accesses_in_arena
- }
+ let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses();
+ accesses_in_shm.chain(accesses_in_arena)
}
}
diff --git a/crates/fspy/src/unix/sigsys.rs b/crates/fspy/src/unix/sigsys.rs
new file mode 100644
index 000000000..3650fb2e0
--- /dev/null
+++ b/crates/fspy/src/unix/sigsys.rs
@@ -0,0 +1,64 @@
+use std::{io, os::fd::RawFd};
+
+#[cfg(target_arch = "x86_64")]
+unsafe extern "C" {
+ fn fspy_sigsys_prepare(shm_fd: libc::c_int) -> libc::c_int;
+ fn fspy_sigsys_inject(
+ pid: libc::pid_t,
+ shm_fd: libc::c_int,
+ shm_len: libc::size_t,
+ ) -> libc::c_int;
+}
+
+/// Marks the child traceable and installs the selective TRAP filter.
+///
+/// # Safety
+///
+/// This must only run in the post-fork child immediately before exec.
+pub unsafe fn prepare(shm_fd: RawFd) -> io::Result<()> {
+ #[cfg(target_arch = "x86_64")]
+ {
+ // SAFETY: the caller guarantees the pre-exec child context and the fd
+ // is the live channel memfd inherited from the parent.
+ if unsafe { fspy_sigsys_prepare(shm_fd) } == 0 {
+ Ok(())
+ } else {
+ Err(io::Error::last_os_error())
+ }
+ }
+
+ #[cfg(not(target_arch = "x86_64"))]
+ {
+ let _ = shm_fd;
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "the experimental SIGSYS injector supports Linux x86-64 only",
+ ))
+ }
+}
+
+/// Waits for the child's post-exec ptrace stop, maps the existing IPC shared
+/// memory, installs the in-process handler, and detaches.
+pub fn inject(pid: u32, shm_fd: RawFd, shm_len: usize) -> io::Result<()> {
+ #[cfg(target_arch = "x86_64")]
+ {
+ let pid = libc::pid_t::try_from(pid)
+ .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "child pid exceeds pid_t"))?;
+ // SAFETY: `pid` is the freshly spawned TRACEME child and the fd/length
+ // identify the channel mapping inherited across its exec.
+ if unsafe { fspy_sigsys_inject(pid, shm_fd, shm_len) } == 0 {
+ Ok(())
+ } else {
+ Err(io::Error::last_os_error())
+ }
+ }
+
+ #[cfg(not(target_arch = "x86_64"))]
+ {
+ let _ = (pid, shm_fd, shm_len);
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "the experimental SIGSYS injector supports Linux x86-64 only",
+ ))
+ }
+}
diff --git a/crates/fspy/src/unix/sigsys_x86_64.c b/crates/fspy/src/unix/sigsys_x86_64.c
new file mode 100644
index 000000000..94595082a
--- /dev/null
+++ b/crates/fspy/src/unix/sigsys_x86_64.c
@@ -0,0 +1,499 @@
+#define _GNU_SOURCE
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if !defined(__linux__) || !defined(__x86_64__)
+#error "the experimental fspy SIGSYS injector supports Linux x86-64 only"
+#endif
+
+#define ARRAY_LEN(values) (sizeof(values) / sizeof((values)[0]))
+#define GATEWAY_MAGIC UINT64_C(0x4653505947415445)
+#define GATEWAY_MAGIC_LOW UINT32_C(0x47415445)
+#define FILTER_TAG UINT32_C(0x4653)
+#define PATH_LIMIT 4096U
+#define INSTALL_ACTION_OFFSET 0U
+
+/*
+ * This blob is copied into the tracee at its post-exec SIGTRAP stop. It has
+ * no relocations, GOT/PLT references, TLS, libc calls, or writable code data.
+ * The injector patches the slots at its tail before making the mapping RX.
+ *
+ * The record writer intentionally mirrors fspy_shared::ipc::channel::ShmWriter:
+ *
+ * usize committed_end
+ * repeated { i32 frame_size; frame bytes; padding to 4-byte alignment }
+ *
+ * A negative frame size commits a complete frame. The frame bytes are the
+ * existing wincode PathAccess encoding on 64-bit little-endian Unix:
+ * AccessMode(u8), path length(u64), path bytes.
+ */
+__asm__(
+ ".pushsection .text.fspy_sigsys_injected,\"ax\",@progbits\n"
+ ".balign 16\n"
+ ".global fspy_sigsys_blob_start\n"
+ ".global fspy_sigsys_handler\n"
+ ".global fspy_sigsys_restorer\n"
+ ".global fspy_sigsys_slot_rax\n"
+ ".global fspy_sigsys_slot_rdi\n"
+ ".global fspy_sigsys_slot_rsi\n"
+ ".global fspy_sigsys_slot_rdx\n"
+ ".global fspy_sigsys_slot_r10\n"
+ ".global fspy_sigsys_slot_r8\n"
+ ".global fspy_sigsys_slot_shm\n"
+ ".global fspy_sigsys_slot_shm_len\n"
+ ".global fspy_sigsys_slot_magic\n"
+ ".global fspy_sigsys_blob_end\n"
+ "fspy_sigsys_blob_start:\n"
+ "fspy_sigsys_handler:\n"
+ " push %rbp\n"
+ " mov %rsp, %rbp\n"
+ " push %rbx\n"
+ " push %r12\n"
+ " push %r13\n"
+ " push %r14\n"
+ " push %r15\n"
+ " mov %rsi, %r12\n" /* siginfo_t * */
+ " mov %rdx, %r13\n" /* ucontext_t * */
+
+ /* Only dispatch traps produced by fspy's tagged filter. */
+ " cmpl $0x4653, 4(%r12)\n"
+ " jne .Lfspy_sigsys_return\n"
+ " mov 24(%r12), %eax\n"
+ " cmp $2, %eax\n" /* __NR_open */
+ " je .Lfspy_sigsys_open\n"
+ " cmp $257, %eax\n" /* __NR_openat */
+ " je .Lfspy_sigsys_openat\n"
+ " mov $-38, %rax\n" /* -ENOSYS */
+ " jmp .Lfspy_sigsys_store_result\n"
+
+ ".Lfspy_sigsys_open:\n"
+ " mov fspy_sigsys_slot_rdi(%rip), %rcx\n"
+ " mov (%r13,%rcx), %r15\n" /* path */
+ " mov fspy_sigsys_slot_rsi(%rip), %rcx\n"
+ " mov (%r13,%rcx), %rbx\n" /* flags */
+ " jmp .Lfspy_sigsys_record\n"
+
+ ".Lfspy_sigsys_openat:\n"
+ " mov fspy_sigsys_slot_rsi(%rip), %rcx\n"
+ " mov (%r13,%rcx), %r15\n" /* path */
+ " mov fspy_sigsys_slot_rdx(%rip), %rcx\n"
+ " mov (%r13,%rcx), %rbx\n" /* flags */
+
+ ".Lfspy_sigsys_record:\n"
+ /* Convert O_ACCMODE into fspy's AccessMode bits. */
+ " and $3, %ebx\n"
+ " cmp $1, %ebx\n"
+ " je .Lfspy_sigsys_write_mode\n"
+ " cmp $2, %ebx\n"
+ " je .Lfspy_sigsys_read_write_mode\n"
+ " mov $1, %ebx\n"
+ " jmp .Lfspy_sigsys_scan_path\n"
+ ".Lfspy_sigsys_write_mode:\n"
+ " mov $2, %ebx\n"
+ " jmp .Lfspy_sigsys_scan_path\n"
+ ".Lfspy_sigsys_read_write_mode:\n"
+ " mov $3, %ebx\n"
+
+ /* Minimal experiment: paths are valid C strings, capped at PATH_MAX. */
+ ".Lfspy_sigsys_scan_path:\n"
+ " xor %ecx, %ecx\n"
+ ".Lfspy_sigsys_scan_path_loop:\n"
+ " cmp $4096, %ecx\n"
+ " jae .Lfspy_sigsys_passthrough\n"
+ " cmpb $0, (%r15,%rcx)\n"
+ " je .Lfspy_sigsys_path_ready\n"
+ " inc %rcx\n"
+ " jmp .Lfspy_sigsys_scan_path_loop\n"
+
+ ".Lfspy_sigsys_path_ready:\n"
+ " mov fspy_sigsys_slot_shm(%rip), %r14\n"
+ " lea 9(%rcx), %r8\n" /* encoded PathAccess size */
+ " mov (%r14), %rax\n" /* current committed_end */
+ ".Lfspy_sigsys_claim_loop:\n"
+ " lea 7(%rax,%r8), %rdx\n" /* old + 4-byte header + frame + 3 */
+ " and $-4, %rdx\n"
+ " mov fspy_sigsys_slot_shm_len(%rip), %rsi\n"
+ " sub $8, %rsi\n"
+ " cmp %rsi, %rdx\n"
+ " ja .Lfspy_sigsys_passthrough\n"
+ " lock cmpxchgq %rdx, (%r14)\n"
+ " jne .Lfspy_sigsys_claim_loop\n"
+
+ " lea 8(%r14,%rax), %rdx\n" /* claimed frame header */
+ " mov %r8d, (%rdx)\n" /* positive: write in progress */
+ " mov %bl, 4(%rdx)\n"
+ " mov %rcx, 5(%rdx)\n"
+ " lea 13(%rdx), %rdi\n"
+ " mov %r15, %rsi\n"
+ " rep movsb\n"
+ /* x86 TSO publishes the bytes before this aligned atomic-sized store. */
+ " neg %r8d\n"
+ " mov %r8d, (%rdx)\n" /* negative: committed */
+
+ ".Lfspy_sigsys_passthrough:\n"
+ " mov 24(%r12), %ebx\n"
+ " mov fspy_sigsys_slot_rdi(%rip), %rcx\n"
+ " mov (%r13,%rcx), %rdi\n"
+ " mov fspy_sigsys_slot_rsi(%rip), %rcx\n"
+ " mov (%r13,%rcx), %rsi\n"
+ " mov fspy_sigsys_slot_rdx(%rip), %rcx\n"
+ " mov (%r13,%rcx), %rdx\n"
+ " mov fspy_sigsys_slot_r10(%rip), %rcx\n"
+ " mov (%r13,%rcx), %r10\n"
+ " mov fspy_sigsys_slot_r8(%rip), %rcx\n"
+ " mov (%r13,%rcx), %r8\n"
+ " mov fspy_sigsys_slot_magic(%rip), %r9\n"
+ " mov %ebx, %eax\n"
+ " syscall\n"
+
+ ".Lfspy_sigsys_store_result:\n"
+ " mov fspy_sigsys_slot_rax(%rip), %rcx\n"
+ " mov %rax, (%r13,%rcx)\n"
+ ".Lfspy_sigsys_return:\n"
+ " pop %r15\n"
+ " pop %r14\n"
+ " pop %r13\n"
+ " pop %r12\n"
+ " pop %rbx\n"
+ " pop %rbp\n"
+ " ret\n"
+
+ ".balign 8\n"
+ "fspy_sigsys_restorer:\n"
+ " mov $15, %eax\n" /* __NR_rt_sigreturn */
+ " syscall\n"
+ " ud2\n"
+ ".balign 8\n"
+ "fspy_sigsys_slot_rax: .quad 0\n"
+ "fspy_sigsys_slot_rdi: .quad 0\n"
+ "fspy_sigsys_slot_rsi: .quad 0\n"
+ "fspy_sigsys_slot_rdx: .quad 0\n"
+ "fspy_sigsys_slot_r10: .quad 0\n"
+ "fspy_sigsys_slot_r8: .quad 0\n"
+ "fspy_sigsys_slot_shm: .quad 0\n"
+ "fspy_sigsys_slot_shm_len: .quad 0\n"
+ "fspy_sigsys_slot_magic: .quad 0\n"
+ "fspy_sigsys_blob_end:\n"
+ ".popsection\n");
+
+extern const unsigned char fspy_sigsys_blob_start[];
+extern const unsigned char fspy_sigsys_handler[];
+extern const unsigned char fspy_sigsys_restorer[];
+extern const unsigned char fspy_sigsys_slot_rax[];
+extern const unsigned char fspy_sigsys_slot_rdi[];
+extern const unsigned char fspy_sigsys_slot_rsi[];
+extern const unsigned char fspy_sigsys_slot_rdx[];
+extern const unsigned char fspy_sigsys_slot_r10[];
+extern const unsigned char fspy_sigsys_slot_r8[];
+extern const unsigned char fspy_sigsys_slot_shm[];
+extern const unsigned char fspy_sigsys_slot_shm_len[];
+extern const unsigned char fspy_sigsys_slot_magic[];
+extern const unsigned char fspy_sigsys_blob_end[];
+
+struct kernel_sigaction_wire {
+ uint64_t handler;
+ uint64_t flags;
+ uint64_t restorer;
+ uint64_t mask;
+};
+
+static size_t blob_offset(const unsigned char *symbol)
+{
+ return (size_t)(symbol - fspy_sigsys_blob_start);
+}
+
+static void patch_u64(unsigned char *blob, const unsigned char *slot,
+ uint64_t value)
+{
+ memcpy(blob + blob_offset(slot), &value, sizeof(value));
+}
+
+static int ptrace_get_regs(pid_t pid, struct user_regs_struct *regs)
+{
+ struct iovec iov = {.iov_base = regs, .iov_len = sizeof(*regs)};
+ return (int)ptrace(PTRACE_GETREGSET, pid, (void *)(uintptr_t)NT_PRSTATUS,
+ &iov);
+}
+
+static int ptrace_set_regs(pid_t pid, const struct user_regs_struct *regs)
+{
+ struct iovec iov = {.iov_base = (void *)regs, .iov_len = sizeof(*regs)};
+ return (int)ptrace(PTRACE_SETREGSET, pid, (void *)(uintptr_t)NT_PRSTATUS,
+ &iov);
+}
+
+static int wait_for_breakpoint(pid_t pid)
+{
+ int status;
+
+ if (waitpid(pid, &status, __WALL) < 0)
+ return -1;
+ if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGTRAP) {
+ errno = EPROTO;
+ return -1;
+ }
+ return 0;
+}
+
+static int remote_syscall(pid_t pid, long number, uint64_t a0, uint64_t a1,
+ uint64_t a2, uint64_t a3, uint64_t a4, uint64_t a5,
+ long *result_out)
+{
+ struct user_regs_struct saved;
+ struct user_regs_struct call_regs;
+ struct user_regs_struct stopped;
+ unsigned long original_word = 0;
+ unsigned long patched_word;
+ uintptr_t pc;
+ int saved_errno;
+ int have_regs = 0;
+ int have_word = 0;
+
+ if (ptrace_get_regs(pid, &saved) < 0)
+ return -1;
+ have_regs = 1;
+ call_regs = saved;
+ pc = (uintptr_t)saved.rip;
+ errno = 0;
+ original_word =
+ (unsigned long)ptrace(PTRACE_PEEKTEXT, pid, (void *)pc, NULL);
+ if (original_word == (unsigned long)-1 && errno != 0)
+ goto fail;
+ have_word = 1;
+ patched_word = original_word;
+ ((unsigned char *)&patched_word)[0] = 0x0f; /* syscall */
+ ((unsigned char *)&patched_word)[1] = 0x05;
+ ((unsigned char *)&patched_word)[2] = 0xcc; /* int3 */
+ if (ptrace(PTRACE_POKETEXT, pid, (void *)pc, (void *)patched_word) < 0)
+ goto fail;
+ call_regs.rax = (uint64_t)number;
+ call_regs.orig_rax = UINT64_MAX;
+ call_regs.rdi = a0;
+ call_regs.rsi = a1;
+ call_regs.rdx = a2;
+ call_regs.r10 = a3;
+ call_regs.r8 = a4;
+ call_regs.r9 = a5;
+ if (ptrace_set_regs(pid, &call_regs) < 0)
+ goto fail;
+ if (ptrace(PTRACE_CONT, pid, NULL, NULL) < 0)
+ goto fail;
+ if (wait_for_breakpoint(pid) < 0)
+ goto fail;
+ if (ptrace_get_regs(pid, &stopped) < 0)
+ goto fail;
+ *result_out = (long)stopped.rax;
+ if (ptrace(PTRACE_POKETEXT, pid, (void *)pc, (void *)original_word) < 0)
+ goto fail;
+ have_word = 0;
+ if (ptrace_set_regs(pid, &saved) < 0)
+ goto fail;
+ have_regs = 0;
+ return 0;
+
+fail:
+ saved_errno = errno;
+ if (have_word)
+ (void)ptrace(PTRACE_POKETEXT, pid, (void *)pc,
+ (void *)original_word);
+ if (have_regs)
+ (void)ptrace_set_regs(pid, &saved);
+ errno = saved_errno;
+ return -1;
+}
+
+static int remote_write(pid_t pid, uintptr_t destination, const void *source,
+ size_t length)
+{
+ const unsigned char *bytes = source;
+ size_t offset = 0;
+
+ while (offset < length) {
+ unsigned long word = 0;
+ size_t chunk = length - offset;
+ if (chunk > sizeof(word))
+ chunk = sizeof(word);
+ memcpy(&word, bytes + offset, chunk);
+ if (ptrace(PTRACE_POKEDATA, pid, (void *)(destination + offset),
+ (void *)word) < 0)
+ return -1;
+ offset += chunk;
+ }
+ return 0;
+}
+
+static int raw_syscall_failed(long result)
+{
+ return result < 0 && result >= -4095;
+}
+
+static int raw_syscall_ok(long result)
+{
+ if (!raw_syscall_failed(result))
+ return 0;
+ errno = (int)-result;
+ return -1;
+}
+
+#define FILTER_GATEWAY_BLOCK(syscall_number) \
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (syscall_number), 0, 4), \
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, \
+ offsetof(struct seccomp_data, args[5])), \
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, GATEWAY_MAGIC_LOW, 1, 0), \
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_TRAP | FILTER_TAG), \
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)
+
+int fspy_sigsys_prepare(int shm_fd)
+{
+ int descriptor_flags = fcntl(shm_fd, F_GETFD);
+ struct sock_filter instructions[] = {
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, arch)),
+ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
+ BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
+ offsetof(struct seccomp_data, nr)),
+ FILTER_GATEWAY_BLOCK(SYS_open),
+ FILTER_GATEWAY_BLOCK(SYS_openat),
+ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
+ };
+ struct sock_fprog program = {
+ .len = (unsigned short)ARRAY_LEN(instructions),
+ .filter = instructions,
+ };
+
+ if (descriptor_flags < 0)
+ return -1;
+ if (fcntl(shm_fd, F_SETFD, descriptor_flags & ~FD_CLOEXEC) < 0)
+ return -1;
+ if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0)
+ return -1;
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
+ return -1;
+ if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &program) < 0)
+ return -1;
+ return 0;
+}
+
+int fspy_sigsys_inject(pid_t pid, int shm_fd, size_t shm_len)
+{
+ const size_t page_size = (size_t)sysconf(_SC_PAGESIZE);
+ const size_t blob_size =
+ (size_t)(fspy_sigsys_blob_end - fspy_sigsys_blob_start);
+ unsigned char *blob = NULL;
+ struct kernel_sigaction_wire action = {0};
+ uintptr_t remote_code;
+ uintptr_t remote_action;
+ uintptr_t remote_shm;
+ long result;
+ int status;
+ int saved_errno;
+
+ if (page_size == 0 || blob_size > page_size ||
+ INSTALL_ACTION_OFFSET + sizeof(action) > page_size || shm_len < 8) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ if (waitpid(pid, &status, __WALL) < 0)
+ return -1;
+ if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGTRAP) {
+ errno = EPROTO;
+ return -1;
+ }
+
+ if (remote_syscall(pid, SYS_mmap, 0, shm_len, PROT_READ | PROT_WRITE,
+ MAP_SHARED, (uint64_t)shm_fd, 0, &result) < 0 ||
+ raw_syscall_ok(result) < 0)
+ goto fail;
+ remote_shm = (uintptr_t)result;
+
+ if (remote_syscall(pid, SYS_mmap, 0, page_size * 2,
+ PROT_READ | PROT_WRITE,
+ MAP_PRIVATE | MAP_ANONYMOUS, UINT64_MAX, 0, &result) <
+ 0 ||
+ raw_syscall_ok(result) < 0)
+ goto fail;
+ remote_code = (uintptr_t)result;
+ remote_action = remote_code + page_size + INSTALL_ACTION_OFFSET;
+
+ blob = malloc(blob_size);
+ if (blob == NULL)
+ goto fail;
+ memcpy(blob, fspy_sigsys_blob_start, blob_size);
+ patch_u64(blob, fspy_sigsys_slot_rax,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_RAX]));
+ patch_u64(blob, fspy_sigsys_slot_rdi,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_RDI]));
+ patch_u64(blob, fspy_sigsys_slot_rsi,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_RSI]));
+ patch_u64(blob, fspy_sigsys_slot_rdx,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_RDX]));
+ patch_u64(blob, fspy_sigsys_slot_r10,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_R10]));
+ patch_u64(blob, fspy_sigsys_slot_r8,
+ offsetof(ucontext_t, uc_mcontext) +
+ offsetof(mcontext_t, gregs[REG_R8]));
+ patch_u64(blob, fspy_sigsys_slot_shm, remote_shm);
+ patch_u64(blob, fspy_sigsys_slot_shm_len, shm_len);
+ patch_u64(blob, fspy_sigsys_slot_magic, GATEWAY_MAGIC);
+ if (remote_write(pid, remote_code, blob, blob_size) < 0)
+ goto fail;
+ free(blob);
+ blob = NULL;
+
+ action.handler = remote_code + blob_offset(fspy_sigsys_handler);
+ action.flags = SA_SIGINFO | SA_NODEFER | UINT64_C(0x04000000);
+ action.restorer = remote_code + blob_offset(fspy_sigsys_restorer);
+ if (remote_write(pid, remote_action, &action, sizeof(action)) < 0)
+ goto fail;
+
+ if (remote_syscall(pid, SYS_mprotect, remote_code, page_size,
+ PROT_READ | PROT_EXEC, 0, 0, 0, &result) < 0 ||
+ raw_syscall_ok(result) < 0 || result != 0)
+ goto fail;
+ if (remote_syscall(pid, SYS_rt_sigaction, SIGSYS, remote_action, 0, 8, 0,
+ GATEWAY_MAGIC, &result) < 0 ||
+ raw_syscall_ok(result) < 0 || result != 0)
+ goto fail;
+ if (remote_syscall(pid, SYS_close, (uint64_t)shm_fd, 0, 0, 0, 0, 0,
+ &result) < 0 ||
+ raw_syscall_ok(result) < 0 || result != 0)
+ goto fail;
+
+ if (ptrace(PTRACE_DETACH, pid, NULL, NULL) < 0)
+ return -1;
+ return 0;
+
+fail:
+ saved_errno = errno;
+ free(blob);
+ (void)ptrace(PTRACE_KILL, pid, NULL, NULL);
+ errno = saved_errno;
+ return -1;
+}
diff --git a/crates/fspy/src/unix/syscall_handler/execve.rs b/crates/fspy/src/unix/syscall_handler/execve.rs
deleted file mode 100644
index d34ac8c2d..000000000
--- a/crates/fspy/src/unix/syscall_handler/execve.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-use std::io;
-
-use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd};
-
-use super::SyscallHandler;
-
-impl SyscallHandler {
- fn handle_execve(&mut self, caller: Caller, fd: Fd, path_ptr: CStrPtr) -> io::Result<()> {
- // TODO: parse shebangs to track reading interpreters
- self.handle_open(caller, fd, path_ptr, libc::O_RDONLY)
- }
-
- pub(super) fn execveat(
- &mut self,
- caller: Caller,
- (fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_execve(caller, fd, path_ptr)
- }
-
- pub(super) fn execve(&mut self, caller: Caller, (path_ptr,): (CStrPtr,)) -> io::Result<()> {
- self.handle_execve(caller, Fd::cwd(), path_ptr)
- }
-}
diff --git a/crates/fspy/src/unix/syscall_handler/getdents.rs b/crates/fspy/src/unix/syscall_handler/getdents.rs
deleted file mode 100644
index 45eec5320..000000000
--- a/crates/fspy/src/unix/syscall_handler/getdents.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use std::io;
-
-use fspy_seccomp_unotify::supervisor::handler::arg::{Caller, Fd};
-
-use super::SyscallHandler;
-
-impl SyscallHandler {
- #[cfg(target_arch = "x86_64")]
- pub(super) fn getdents(&mut self, caller: Caller, (fd,): (Fd,)) -> io::Result<()> {
- self.handle_open_dir(caller, fd)
- }
-
- pub(super) fn getdents64(&mut self, caller: Caller, (fd,): (Fd,)) -> io::Result<()> {
- self.handle_open_dir(caller, fd)
- }
-}
diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs
deleted file mode 100644
index 4b6f7947e..000000000
--- a/crates/fspy/src/unix/syscall_handler/mod.rs
+++ /dev/null
@@ -1,103 +0,0 @@
-mod execve;
-mod getdents;
-mod open;
-mod stat;
-
-use std::{
- borrow::Cow,
- ffi::{OsStr, c_int},
- io,
- os::unix::ffi::OsStrExt,
- path::{Path, PathBuf},
-};
-
-use fspy_seccomp_unotify::{
- impl_handler,
- supervisor::handler::arg::{CStrPtr, Caller, Fd},
-};
-use fspy_shared::ipc::{AccessMode, PathAccess};
-
-use crate::arena::PathAccessArena;
-
-const PATH_MAX: usize = libc::PATH_MAX as usize;
-
-#[derive(Debug)]
-pub struct SyscallHandler {
- arena: PathAccessArena,
- path_read_buf: [u8; PATH_MAX],
-}
-
-impl Default for SyscallHandler {
- fn default() -> Self {
- Self { arena: PathAccessArena::default(), path_read_buf: [0; PATH_MAX] }
- }
-}
-
-impl SyscallHandler {
- pub fn into_arena(self) -> PathAccessArena {
- self.arena
- }
-
- fn handle_open(
- &mut self,
- caller: Caller,
- dir_fd: Fd,
- path_ptr: CStrPtr,
- flags: c_int,
- ) -> io::Result<()> {
- let Some(path_len) = path_ptr.read(caller, &mut self.path_read_buf)? else {
- // Ignore paths that are too long to fit in PATH_MAX
- return Ok(());
- };
- let mut path = Cow::Borrowed(Path::new(OsStr::from_bytes(&self.path_read_buf[..path_len])));
- if !path.is_absolute() {
- let mut resolved_path = PathBuf::from(dir_fd.get_path(caller)?);
- if !nix::NixPath::is_empty(path.as_ref()) {
- resolved_path.push(&path);
- }
- path = Cow::Owned(resolved_path);
- }
- self.arena.add(PathAccess {
- mode: match flags & libc::O_ACCMODE {
- libc::O_RDWR => AccessMode::READ | AccessMode::WRITE,
- libc::O_WRONLY => AccessMode::WRITE,
- _ => AccessMode::READ,
- },
- path: path.as_os_str().into(),
- });
- Ok(())
- }
-
- fn handle_open_dir(&mut self, caller: Caller, fd: Fd) -> io::Result<()> {
- let path = fd.get_path(caller)?;
- self.arena.add(PathAccess {
- mode: AccessMode::READ_DIR,
- path: OsStr::from_bytes(path.as_bytes()).into(),
- });
- Ok(())
- }
-}
-
-impl_handler!(
- SyscallHandler:
-
- #[cfg(target_arch = "x86_64")] open,
- openat,
- openat2,
-
- #[cfg(target_arch = "x86_64")] getdents,
- getdents64,
-
- #[cfg(target_arch = "x86_64")] stat,
- #[cfg(target_arch = "x86_64")] lstat,
- #[cfg(target_arch = "x86_64")] newfstatat,
- #[cfg(target_arch = "aarch64")] fstatat,
- statx,
-
- #[cfg(target_arch = "x86_64")] access,
- faccessat,
- faccessat2,
-
- execve,
- execveat,
-);
diff --git a/crates/fspy/src/unix/syscall_handler/open.rs b/crates/fspy/src/unix/syscall_handler/open.rs
deleted file mode 100644
index be7ae157e..000000000
--- a/crates/fspy/src/unix/syscall_handler/open.rs
+++ /dev/null
@@ -1,35 +0,0 @@
-use std::{ffi::c_int, io};
-
-use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd, Ptr};
-
-use super::SyscallHandler;
-
-impl SyscallHandler {
- #[cfg(target_arch = "x86_64")]
- pub(super) fn open(
- &mut self,
- caller: Caller,
- (path, flags): (CStrPtr, c_int),
- ) -> io::Result<()> {
- self.handle_open(caller, Fd::cwd(), path, flags)
- }
-
- pub(super) fn openat(
- &mut self,
- caller: Caller,
- (dir_fd, path, flags): (Fd, CStrPtr, c_int),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path, flags)
- }
-
- pub(super) fn openat2(
- &mut self,
- caller: Caller,
- // open_how is a pointer to struct `open_how`, but we only care about flags here, so use `Ptr`
- (dir_fd, path, open_how): (Fd, CStrPtr, Ptr),
- ) -> io::Result<()> {
- // SAFETY: open_how is a valid pointer to struct `open_how` in the target process, which has `flags` as the first field of type `u64`
- let flags = unsafe { open_how.read(caller) }?;
- self.handle_open(caller, dir_fd, path, c_int::try_from(flags).unwrap_or(libc::O_RDWR))
- }
-}
diff --git a/crates/fspy/src/unix/syscall_handler/stat.rs b/crates/fspy/src/unix/syscall_handler/stat.rs
deleted file mode 100644
index 40d9f76f1..000000000
--- a/crates/fspy/src/unix/syscall_handler/stat.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-use std::io;
-
-use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd};
-
-use super::SyscallHandler;
-
-impl SyscallHandler {
- #[cfg(target_arch = "x86_64")]
- pub(super) fn stat(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
- self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
- }
-
- #[cfg(target_arch = "x86_64")]
- pub(super) fn lstat(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
- self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
- }
-
- #[cfg(target_arch = "aarch64")]
- pub(super) fn fstatat(
- &mut self,
- caller: Caller,
- (dir_fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
- }
-
- #[cfg(target_arch = "x86_64")]
- pub(super) fn newfstatat(
- &mut self,
- caller: Caller,
- (dir_fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
- }
-
- /// statx(2) — modern replacement for stat/fstatat used by newer glibc.
- pub(super) fn statx(
- &mut self,
- caller: Caller,
- (dir_fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
- }
-
- /// access(2) — check file accessibility (e.g. existsSync in Node.js).
- #[cfg(target_arch = "x86_64")]
- pub(super) fn access(&mut self, caller: Caller, (path,): (CStrPtr,)) -> io::Result<()> {
- self.handle_open(caller, Fd::cwd(), path, libc::O_RDONLY)
- }
-
- /// faccessat(2) — check file accessibility relative to directory fd.
- pub(super) fn faccessat(
- &mut self,
- caller: Caller,
- (dir_fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
- }
-
- /// faccessat2(2) — extended faccessat with flags parameter.
- pub(super) fn faccessat2(
- &mut self,
- caller: Caller,
- (dir_fd, path_ptr): (Fd, CStrPtr),
- ) -> io::Result<()> {
- self.handle_open(caller, dir_fd, path_ptr, libc::O_RDONLY)
- }
-}
diff --git a/crates/fspy_benchmark/README.md b/crates/fspy_benchmark/README.md
index 7d722b214..de74de285 100644
--- a/crates/fspy_benchmark/README.md
+++ b/crates/fspy_benchmark/README.md
@@ -5,7 +5,7 @@ Measures what fspy costs a process it tracks, and whether a change to fspy moved
- `launch`: the wall clock of a tracked launch that opens nothing. This is the cost of starting a process under tracking: injection, session setup, and teardown.
- `access`: how long a batch of opens takes, timed by two threads that each open their own path. This is the cost of interception itself, under the concurrency a tracked process normally has.
-Linux measures a dynamically linked target (`LD_PRELOAD`) and a `x86_64-unknown-linux-musl` target (seccomp user notification). macOS measures `DYLD_INSERT_LIBRARIES`. Windows measures Detours injection.
+Linux measures both a dynamically linked target and a `x86_64-unknown-linux-musl` target. Those labels describe the workload, not the interception backend, so the same rows can compare backend changes. macOS measures `DYLD_INSERT_LIBRARIES`. Windows measures Detours injection.
Run the overhead report locally with:
diff --git a/crates/fspy_preload_unix/src/lib.rs b/crates/fspy_preload_unix/src/lib.rs
index 42bf9e9cb..cd5c207ed 100644
--- a/crates/fspy_preload_unix/src/lib.rs
+++ b/crates/fspy_preload_unix/src/lib.rs
@@ -1,13 +1,11 @@
-// Compile as an empty crate on non-unix targets and on musl (where seccomp
-// alone handles access tracking). Guarding the feature gate keeps rustc from
-// warning about unused features on those targets.
-#![cfg_attr(all(unix, not(target_env = "musl")), feature(c_variadic))]
+// Linux uses the ptrace/SIGSYS backend. Keep this cdylib only for macOS.
+#![cfg_attr(target_os = "macos", feature(c_variadic))]
-#[cfg(all(unix, not(target_env = "musl")))]
+#[cfg(target_os = "macos")]
mod client;
-#[cfg(all(unix, not(target_env = "musl")))]
+#[cfg(target_os = "macos")]
mod interceptions;
-#[cfg(all(unix, not(target_env = "musl")))]
+#[cfg(target_os = "macos")]
mod libc;
-#[cfg(all(unix, not(target_env = "musl")))]
+#[cfg(target_os = "macos")]
mod macros;
diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs
index ae34c8ca6..6558e9419 100644
--- a/crates/fspy_shared/src/ipc/channel/mod.rs
+++ b/crates/fspy_shared/src/ipc/channel/mod.rs
@@ -2,6 +2,8 @@
mod shm_io;
+#[cfg(target_os = "linux")]
+use std::os::fd::{AsFd, BorrowedFd};
use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc};
use fspy_shm::Shm;
@@ -175,6 +177,20 @@ impl Receiver {
let reader = ShmReader::new(unsafe { self.shm.as_slice() });
Ok(ReceiverLockGuard { reader, lock_file: &self.lock_file })
}
+
+ /// Returns the channel's backing memfd for mapping into a Linux tracee.
+ #[cfg(target_os = "linux")]
+ #[must_use]
+ pub fn shm_fd(&self) -> BorrowedFd<'_> {
+ self.shm.as_fd()
+ }
+
+ /// Returns the channel's mapped capacity.
+ #[cfg(target_os = "linux")]
+ #[must_use]
+ pub fn shm_len(&self) -> usize {
+ self.shm.len()
+ }
}
pub struct ReceiverLockGuard<'a> {
diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs
index c7236e5d6..e272e1261 100644
--- a/crates/fspy_shared/src/ipc/mod.rs
+++ b/crates/fspy_shared/src/ipc/mod.rs
@@ -1,4 +1,3 @@
-#[cfg(not(target_env = "musl"))]
pub mod channel;
mod native_path;
use std::fmt::Debug;
diff --git a/crates/fspy_shared_unix/Cargo.toml b/crates/fspy_shared_unix/Cargo.toml
index 38b9301bb..99ba43a77 100644
--- a/crates/fspy_shared_unix/Cargo.toml
+++ b/crates/fspy_shared_unix/Cargo.toml
@@ -18,7 +18,6 @@ stackalloc = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
elf = { workspace = true }
-fspy_seccomp_unotify = { workspace = true, features = ["target"] }
memmap2 = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
diff --git a/crates/fspy_shared_unix/src/payload.rs b/crates/fspy_shared_unix/src/payload.rs
index 5267bd42e..7bd5f2438 100644
--- a/crates/fspy_shared_unix/src/payload.rs
+++ b/crates/fspy_shared_unix/src/payload.rs
@@ -2,29 +2,22 @@ use std::os::unix::ffi::OsStringExt;
use base64::{Engine as _, prelude::BASE64_STANDARD_NO_PAD};
use bstr::BString;
-#[cfg(not(target_env = "musl"))]
+#[cfg(target_os = "macos")]
use fspy_shared::ipc::NativeStr;
-#[cfg(not(target_env = "musl"))]
+#[cfg(target_os = "macos")]
use fspy_shared::ipc::channel::ChannelConf;
use wincode::{SchemaRead, SchemaWrite};
#[derive(Debug, SchemaWrite, SchemaRead)]
pub struct Payload {
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
pub ipc_channel_conf: ChannelConf,
- #[cfg(not(target_env = "musl"))]
+ #[cfg(target_os = "macos")]
pub preload_path: Box,
#[cfg(target_os = "macos")]
pub artifacts: Artifacts,
-
- #[cfg(target_os = "linux")]
- #[cfg_attr(
- not(target_env = "musl"),
- expect(clippy::struct_field_names, reason = "descriptive field name for clarity")
- )]
- pub seccomp_payload: fspy_seccomp_unotify::payload::SeccompPayload,
}
#[cfg(target_os = "macos")]
diff --git a/crates/fspy_shared_unix/src/spawn/linux/mod.rs b/crates/fspy_shared_unix/src/spawn/linux/mod.rs
index d3197da00..40ec4b7dc 100644
--- a/crates/fspy_shared_unix/src/spawn/linux/mod.rs
+++ b/crates/fspy_shared_unix/src/spawn/linux/mod.rs
@@ -1,63 +1,32 @@
-#[cfg(not(target_env = "musl"))]
-use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path};
+use std::convert::Infallible;
-use fspy_seccomp_unotify::{payload::SeccompPayload, target::install_target};
-#[cfg(not(target_env = "musl"))]
-use memmap2::Mmap;
-
-#[cfg(not(target_env = "musl"))]
-use crate::{
- elf,
- exec::{append_path_env, ensure_env},
- open_exec::open_executable,
-};
use crate::{
exec::Exec,
payload::{EncodedPayload, PAYLOAD_ENV_NAME},
};
-const LD_PRELOAD: &str = "LD_PRELOAD";
-
-pub struct PreExec(SeccompPayload);
+pub struct PreExec(Infallible);
impl PreExec {
- /// Installs the seccomp unotify filter for the current process.
+ /// Linux command preparation is performed by fspy's ptrace/SIGSYS path.
///
/// # Errors
///
- /// Returns an error if the seccomp filter installation fails.
- pub fn run(&self) -> nix::Result<()> {
- install_target(&self.0)
+ /// This function is unreachable because Linux never constructs `PreExec`.
+ pub const fn run(&self) -> nix::Result<()> {
+ match self.0 {}
}
}
+#[expect(
+ clippy::unnecessary_wraps,
+ reason = "keeps the platform-specific command preparation signature uniform"
+)]
pub fn handle_exec(
command: &mut Exec,
- encoded_payload: &EncodedPayload,
+ _encoded_payload: &EncodedPayload,
) -> nix::Result