From 06f8432a3e23fd6541f74a42192de29b109bbeec Mon Sep 17 00:00:00 2001 From: wan9chi Date: Tue, 28 Jul 2026 18:37:07 +0800 Subject: [PATCH] refactor(fspy): replace seccomp unotify with filtered ptrace Co-authored-by: GPT-5.6 Codex --- CHANGELOG.md | 1 + Cargo.lock | 11 +- Cargo.toml | 5 +- crates/fspy/Cargo.toml | 2 +- crates/fspy/README.md | 4 +- crates/fspy/src/error.rs | 2 +- crates/fspy/src/unix/mod.rs | 4 +- .../fspy/src/unix/syscall_handler/execve.rs | 2 +- .../fspy/src/unix/syscall_handler/getdents.rs | 2 +- crates/fspy/src/unix/syscall_handler/mod.rs | 2 +- crates/fspy/src/unix/syscall_handler/open.rs | 2 +- crates/fspy/src/unix/syscall_handler/stat.rs | 2 +- crates/fspy/src/windows/mod.rs | 6 +- crates/fspy/tests/static_executable.rs | 17 +- crates/fspy_benchmark/Cargo.toml | 1 - crates/fspy_benchmark/README.md | 7 +- crates/fspy_benchmark/src/main.rs | 141 +++++++-- crates/fspy_preload_unix/src/lib.rs | 6 +- .../.clippy.toml | 0 .../Cargo.toml | 10 +- crates/fspy_seccomp_ptrace/README.md | 6 + .../fspy_seccomp_ptrace/src/bindings/mod.rs | 35 +++ .../src/lib.rs | 2 +- .../src/payload/filter.rs | 0 .../src/payload/mod.rs | 2 +- .../src/supervisor/handler/arg.rs | 44 +-- .../src/supervisor/handler/mod.rs | 113 ++++++++ .../fspy_seccomp_ptrace/src/supervisor/mod.rs | 269 ++++++++++++++++++ crates/fspy_seccomp_ptrace/src/target.rs | 41 +++ .../tests/arg_types.rs | 69 ++++- crates/fspy_seccomp_unotify/README.md | 6 - .../src/bindings/alloc.rs | 106 ------- .../fspy_seccomp_unotify/src/bindings/mod.rs | 87 ------ .../src/supervisor/handler/mod.rs | 47 --- .../src/supervisor/listener.rs | 95 ------- .../src/supervisor/mod.rs | 129 --------- crates/fspy_seccomp_unotify/src/target.rs | 33 --- crates/fspy_shared_unix/Cargo.toml | 2 +- crates/fspy_shared_unix/src/payload.rs | 2 +- .../fspy_shared_unix/src/spawn/linux/mod.rs | 8 +- crates/vite_powershell/src/lib.rs | 9 +- .../preexisting_ld_preload/snapshots.toml | 2 +- .../vite_task_bin/tests/e2e_snapshots/main.rs | 2 +- 43 files changed, 723 insertions(+), 613 deletions(-) rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/.clippy.toml (100%) rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/Cargo.toml (78%) create mode 100644 crates/fspy_seccomp_ptrace/README.md create mode 100644 crates/fspy_seccomp_ptrace/src/bindings/mod.rs rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/src/lib.rs (73%) rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/src/payload/filter.rs (100%) rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/src/payload/mod.rs (87%) rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/src/supervisor/handler/arg.rs (84%) create mode 100644 crates/fspy_seccomp_ptrace/src/supervisor/handler/mod.rs create mode 100644 crates/fspy_seccomp_ptrace/src/supervisor/mod.rs create mode 100644 crates/fspy_seccomp_ptrace/src/target.rs rename crates/{fspy_seccomp_unotify => fspy_seccomp_ptrace}/tests/arg_types.rs (65%) delete mode 100644 crates/fspy_seccomp_unotify/README.md delete mode 100644 crates/fspy_seccomp_unotify/src/bindings/alloc.rs delete mode 100644 crates/fspy_seccomp_unotify/src/bindings/mod.rs delete mode 100644 crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs delete mode 100644 crates/fspy_seccomp_unotify/src/supervisor/listener.rs delete mode 100644 crates/fspy_seccomp_unotify/src/supervisor/mod.rs delete mode 100644 crates/fspy_seccomp_unotify/src/target.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5c30f41..b75cf9b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Changed** Automatic input tracking for static Linux binaries and musl builds now uses seccomp-filtered `ptrace` instead of seccomp user notification, while preserving direct-syscall coverage. - **Added** Tasks now run with `VP_RUN=1` set, so tools can tell they are running under `vp run` instead of being invoked directly ([#570](https://github.com/voidzero-dev/vite-task/pull/570)). - **Fixed** The task cache now supports much larger automatically tracked input sets without hitting wincode's default 4 MiB sequence preallocation limit ([#554](https://github.com/voidzero-dev/vite-task/pull/554)). - **Fixed** npm workspace patterns beginning with `./` now discover matching packages correctly ([vite-plus#2201](https://github.com/voidzero-dev/vite-plus/issues/2201), [#547](https://github.com/voidzero-dev/vite-task/pull/547)). diff --git a/Cargo.lock b/Cargo.lock index 351ca9422..2c5ad7134 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,7 +1213,7 @@ dependencies = [ "fspy_detours_sys", "fspy_preload_unix", "fspy_preload_windows", - "fspy_seccomp_unotify", + "fspy_seccomp_ptrace", "fspy_shared", "fspy_shared_unix", "fspy_test_bin", @@ -1319,14 +1319,14 @@ dependencies = [ ] [[package]] -name = "fspy_seccomp_unotify" +name = "fspy_seccomp_ptrace" version = "0.1.0" dependencies = [ "assertables", "futures-util", "libc", "nix 0.31.2", - "passfd", + "rustc-hash", "seccompiler", "syscalls", "tempfile", @@ -1367,7 +1367,7 @@ dependencies = [ "base64", "bstr", "elf", - "fspy_seccomp_unotify", + "fspy_seccomp_ptrace", "fspy_shared", "memmap2", "nix 0.31.2", @@ -3219,7 +3219,8 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "seccompiler" version = "0.5.0" -source = "git+https://github.com/rust-vmm/seccompiler?rev=08587106340b8e3cb361c7561411510039436857#08587106340b8e3cb361c7561411510039436857" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" dependencies = [ "libc", ] diff --git a/Cargo.toml b/Cargo.toml index 7e1382b6b..821dd4e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ fspy_benchmark_target = { path = "crates/fspy_benchmark_target", artifact = "bin fspy_detours_sys = { path = "crates/fspy_detours_sys" } fspy_preload_unix = { path = "crates/fspy_preload_unix", artifact = "cdylib", target = "target" } fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdylib", target = "target" } -fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" } +fspy_seccomp_ptrace = { path = "crates/fspy_seccomp_ptrace" } fspy_shm = { path = "crates/fspy_shm" } fspy_shared = { path = "crates/fspy_shared" } fspy_shared_unix = { path = "crates/fspy_shared_unix" } @@ -119,8 +119,7 @@ ref-cast = "1.0.24" regex = "1.11.3" rusqlite = "0.39.0" rustc-hash = "2.1.1" -# 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" } +seccompiler = "0.5.0" serde = "1.0.219" serde_json = "1.0.140" serde_norway = "0.9.42" diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 38b9ffb51..e64ab9969 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -23,7 +23,7 @@ tokio-util = { workspace = true } which = { workspace = true, features = ["tracing"] } [target.'cfg(target_os = "linux")'.dependencies] -fspy_seccomp_unotify = { workspace = true, features = ["supervisor"] } +fspy_seccomp_ptrace = { workspace = true, features = ["supervisor"] } nix = { workspace = true, features = ["uio"] } tokio = { workspace = true, features = ["bytes"] } diff --git a/crates/fspy/README.md b/crates/fspy/README.md index cf7fcba0e..dddf32159 100644 --- a/crates/fspy/README.md +++ b/crates/fspy/README.md @@ -9,11 +9,11 @@ The injection process is almost identical on both platforms other than the envir ## Linux-specific implementation for fully static binaries -For fully static binaries (such as `esbuild`), `LD_PRELOAD` does not work. In this case, `seccomp_unotify` is used to intercept direct system calls. The handler is implemented in `src/unix/syscall_handler`. +For fully static binaries (such as `esbuild`), `LD_PRELOAD` does not work. In this case, a seccomp filter routes selected system calls through a `ptrace` supervisor. The handler is implemented in `src/unix/syscall_handler`. ## Linux musl implementation -On musl targets, only `seccomp_unotify`-based tracking is used (no preload library). +On musl targets, only seccomp-filtered `ptrace` tracking is used (no preload library). ## Windows implementation diff --git a/crates/fspy/src/error.rs b/crates/fspy/src/error.rs index 017d82a0e..df1dbf146 100644 --- a/crates/fspy/src/error.rs +++ b/crates/fspy/src/error.rs @@ -13,7 +13,7 @@ pub enum SpawnError { cause: which::Error, }, - #[error("failed to initialize seccomp_unotify supervisor: {0}")] + #[error("failed to initialize seccomp-filtered ptrace supervisor: {0}")] Supervisor(std::io::Error), #[error("failed to create IPC channel: {0}")] diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index f01f63b5d..ea15288a1 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -7,7 +7,7 @@ mod macos_artifacts; use std::{io, path::Path}; #[cfg(target_os = "linux")] -use fspy_seccomp_unotify::supervisor::supervise; +use fspy_seccomp_ptrace::supervisor::supervise; use fspy_shared::ipc::PathAccess; #[cfg(not(target_env = "musl"))] use fspy_shared::ipc::{NativeStr, channel::channel}; @@ -92,7 +92,7 @@ impl SpyImpl { preload_path: self.preload_path.clone(), #[cfg(target_os = "linux")] - seccomp_payload: supervisor.payload().clone(), + ptrace_payload: supervisor.payload().clone(), }; let encoded_payload = encode_payload(payload); diff --git a/crates/fspy/src/unix/syscall_handler/execve.rs b/crates/fspy/src/unix/syscall_handler/execve.rs index d34ac8c2d..4f9cc1e52 100644 --- a/crates/fspy/src/unix/syscall_handler/execve.rs +++ b/crates/fspy/src/unix/syscall_handler/execve.rs @@ -1,6 +1,6 @@ use std::io; -use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd}; +use fspy_seccomp_ptrace::supervisor::handler::arg::{CStrPtr, Caller, Fd}; use super::SyscallHandler; diff --git a/crates/fspy/src/unix/syscall_handler/getdents.rs b/crates/fspy/src/unix/syscall_handler/getdents.rs index 45eec5320..671c1430e 100644 --- a/crates/fspy/src/unix/syscall_handler/getdents.rs +++ b/crates/fspy/src/unix/syscall_handler/getdents.rs @@ -1,6 +1,6 @@ use std::io; -use fspy_seccomp_unotify::supervisor::handler::arg::{Caller, Fd}; +use fspy_seccomp_ptrace::supervisor::handler::arg::{Caller, Fd}; use super::SyscallHandler; diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index 4b6f7947e..a0b6a2ba9 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -11,7 +11,7 @@ use std::{ path::{Path, PathBuf}, }; -use fspy_seccomp_unotify::{ +use fspy_seccomp_ptrace::{ impl_handler, supervisor::handler::arg::{CStrPtr, Caller, Fd}, }; diff --git a/crates/fspy/src/unix/syscall_handler/open.rs b/crates/fspy/src/unix/syscall_handler/open.rs index be7ae157e..d2abe0ba2 100644 --- a/crates/fspy/src/unix/syscall_handler/open.rs +++ b/crates/fspy/src/unix/syscall_handler/open.rs @@ -1,6 +1,6 @@ use std::{ffi::c_int, io}; -use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd, Ptr}; +use fspy_seccomp_ptrace::supervisor::handler::arg::{CStrPtr, Caller, Fd, Ptr}; use super::SyscallHandler; diff --git a/crates/fspy/src/unix/syscall_handler/stat.rs b/crates/fspy/src/unix/syscall_handler/stat.rs index 40d9f76f1..0e88a9d89 100644 --- a/crates/fspy/src/unix/syscall_handler/stat.rs +++ b/crates/fspy/src/unix/syscall_handler/stat.rs @@ -1,6 +1,6 @@ use std::io; -use fspy_seccomp_unotify::supervisor::handler::arg::{CStrPtr, Caller, Fd}; +use fspy_seccomp_ptrace::supervisor::handler::arg::{CStrPtr, Caller, Fd}; use super::SyscallHandler; diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 8081e1298..d14438d27 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -66,7 +66,11 @@ impl SpyImpl { Ok(Self { ansi_dll_path_with_nul: ansi_dll_path_with_nul.into() }) } - #[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")] + #[expect( + clippy::unused_async, + clippy::unused_async_trait_impl, + reason = "async signature must match the Unix SpyImpl" + )] pub(crate) async fn spawn( &self, mut command: Command, diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index ae3c27169..40edb5ee7 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -1,4 +1,4 @@ -//! Tests for fspy tracing of statically-linked executables (seccomp path). +//! Tests for fspy tracing of statically-linked executables (seccomp-filtered ptrace path). //! Skipped on musl: the test binary is an artifact dep targeting musl, and when //! the CI builds with `-crt-static` the binary becomes dynamically linked, //! defeating the purpose of these tests. @@ -120,3 +120,18 @@ async fn execve() { let accesses = track_test_bin(&["execve", "/hello"], None).await; assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::READ); } + +#[test(tokio::test)] +async fn spawned_from_dynamic_parent() { + let test_bin = test_bin_path().to_str().unwrap().to_owned(); + let accesses = track_fn!(test_bin, |test_bin: String| { + let status = std::process::Command::new(test_bin) + .args(["open_read", "/fspy-static-child"]) + .status() + .unwrap(); + assert!(status.success()); + }) + .await + .unwrap(); + assert_contains(&accesses, Path::new("/fspy-static-child"), fspy::AccessMode::READ); +} diff --git a/crates/fspy_benchmark/Cargo.toml b/crates/fspy_benchmark/Cargo.toml index 01e640e6d..39fb40c55 100644 --- a/crates/fspy_benchmark/Cargo.toml +++ b/crates/fspy_benchmark/Cargo.toml @@ -12,7 +12,6 @@ workspace = true [[bin]] name = "fspy_benchmark" -test = false doctest = false [dependencies] diff --git a/crates/fspy_benchmark/README.md b/crates/fspy_benchmark/README.md index 7d722b214..d719333f1 100644 --- a/crates/fspy_benchmark/README.md +++ b/crates/fspy_benchmark/README.md @@ -1,11 +1,12 @@ # fspy benchmark -Measures what fspy costs a process it tracks, and whether a change to fspy moved that cost. It reports two rows: +Measures what fspy costs a process it tracks, and whether a change to fspy moved that cost. It reports three rows: - `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. +- `access-wall`: the wall clock of the same access workload, from spawn through reap. This is its end-to-end cost, including launch, all opens, and teardown. -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 a dynamically linked target (`LD_PRELOAD`) and a `x86_64-unknown-linux-musl` target (seccomp-filtered `ptrace`). macOS measures `DYLD_INSERT_LIBRARIES`. Windows measures Detours injection. Run the overhead report locally with: @@ -19,7 +20,7 @@ Comparing a pull request run against a saved result from an earlier `main` run d So nothing is ever compared across runs. Both fspy revisions run side by side on one machine, in one job. Three pieces make that possible: -- `fspy_benchmark_target` (and its static twin) is the workload. It opens missing paths from several threads and prints the median batch time. It does not link fspy. Batches are a few microseconds long, so the median lands on batches the scheduler left alone; a whole-run wall clock would add up every disturbance instead. +- `fspy_benchmark_target` (and its static twin) is the workload. It opens missing paths from several threads and prints the median batch time. It does not link fspy. Batches are a few microseconds long, so the `access` median lands on batches the scheduler left alone. The `access-wall` row intentionally includes every disturbance to show end-to-end latency, so expect more variance there. - `fspy_benchmark_launcher` runs the target once, tracked through `fspy::Command` or untracked, and prints the launch wall clock plus the target's number. It is the only piece that links fspy. - `fspy_benchmark` is the harness. CI builds the launcher twice: against the fspy under review, and against the merge-base fspy. Both builds use the same launcher source, so both revisions are measured by identical code. The harness then launches both builds back to back, cycling every ordering. Whatever the runner does to the numbers, it does to both. diff --git a/crates/fspy_benchmark/src/main.rs b/crates/fspy_benchmark/src/main.rs index c4567104a..35f1a219f 100644 --- a/crates/fspy_benchmark/src/main.rs +++ b/crates/fspy_benchmark/src/main.rs @@ -42,32 +42,45 @@ enum Metric { Typical, } -struct Suite { +#[derive(Clone, Copy)] +struct MetricReport { name: &'static str, + metric: Metric, +} + +struct Suite { /// Opens per target thread, passed through to it. opens: &'static str, /// Measured iterations. Each one launches every arm once. iterations: usize, /// Unmeasured iterations run first, to fill caches and settle the runner. warmup: usize, - metric: Metric, + /// Measurements reported from the launches in this suite. + reports: &'static [MetricReport], } +const LAUNCH_REPORTS: &[MetricReport] = &[MetricReport { name: "launch", metric: Metric::Wall }]; + /// Opens nothing, so the whole launch is the cost of starting a tracked /// process. Launches cost several times more wall clock on Windows, so it /// affords fewer of them in the same time. const LAUNCH_SUITE: Suite = Suite { - name: "launch", opens: "0", iterations: if cfg!(windows) { 150 } else { 300 }, warmup: 5, - metric: Metric::Wall, + reports: LAUNCH_REPORTS, }; +const ACCESS_REPORTS: &[MetricReport] = &[ + MetricReport { name: "access", metric: Metric::Typical }, + MetricReport { name: "access-wall", metric: Metric::Wall }, +]; + /// Opens timed from inside the target, so they price interception rather than -/// the launch around it. +/// the launch around it. The launcher wall clock from the same runs is also +/// reported to show the end-to-end cost of the workload. const ACCESS_SUITE: Suite = - Suite { name: "access", opens: "2048", iterations: 102, warmup: 3, metric: Metric::Typical }; + Suite { opens: "2048", iterations: 102, warmup: 3, reports: ACCESS_REPORTS }; struct Backend { name: &'static str, @@ -111,12 +124,28 @@ fn parse_base_launcher() -> Option { base_launcher } -/// One iteration's measurements, one per arm, in nanoseconds. -#[derive(Default)] +/// Measurements from one target launch, in nanoseconds. +#[derive(Clone, Copy, Default)] +struct Measurement { + wall: f64, + typical: f64, +} + +impl Measurement { + const fn value(self, metric: Metric) -> f64 { + match metric { + Metric::Wall => self.wall, + Metric::Typical => self.typical, + } + } +} + +/// One iteration's measurements, one launch per arm. +#[derive(Clone, Copy, Default)] struct Iteration { - base: f64, - head: f64, - untracked: f64, + base: Measurement, + head: Measurement, + untracked: Measurement, } /// Every ordering of the arms of an iteration. Cycling through all of them — @@ -168,30 +197,61 @@ fn run_suite(backend: &Backend, suite: &Suite, base_launcher: Option<&OsStr>) { } } - report(backend, suite, &measured, base_launcher.is_some()); + for metric_report in suite.reports { + report(backend, *metric_report, &measured, base_launcher.is_some()); + } } /// Prints the suite's row. #[expect(clippy::print_stdout, reason = "the report is the benchmark's output")] -fn report(backend: &Backend, suite: &Suite, iterations: &[Iteration], has_base: bool) { - let name = [backend.name, "/", suite.name].concat(); - let overheads = sorted(iterations.iter().map(|it| it.head / it.untracked - 1.0)); - let overhead = quantile(&overheads, 1, 2); +fn report( + backend: &Backend, + metric_report: MetricReport, + iterations: &[Iteration], + has_base: bool, +) { + let name = [backend.name, "/", metric_report.name].concat(); + let summary = summarize(iterations, metric_report.metric, has_base); - if has_base { - let changes = sorted(iterations.iter().map(|it| it.head / it.base - 1.0)); + if let Some(change) = summary.change { println!( "{name:<26} change {:>+6.2}% [{:>+6.2}% .. {:>+6.2}%] overhead {:>+8.2}%", - quantile(&changes, 1, 2) * 100.0, - quantile(&changes, 1, 4) * 100.0, - quantile(&changes, 3, 4) * 100.0, - overhead * 100.0, + change.median * 100.0, + change.first_quartile * 100.0, + change.third_quartile * 100.0, + summary.overhead * 100.0, ); } else { - println!("{name:<26} overhead {:>+8.2}%", overhead * 100.0); + println!("{name:<26} overhead {:>+8.2}%", summary.overhead * 100.0); } } +struct Summary { + change: Option, + overhead: f64, +} + +struct Change { + median: f64, + first_quartile: f64, + third_quartile: f64, +} + +fn summarize(iterations: &[Iteration], metric: Metric, has_base: bool) -> Summary { + let value = |measurement: Measurement| measurement.value(metric); + let overheads = sorted(iterations.iter().map(|it| value(it.head) / value(it.untracked) - 1.0)); + let overhead = quantile(&overheads, 1, 2); + let change = has_base.then(|| { + let changes = sorted(iterations.iter().map(|it| value(it.head) / value(it.base) - 1.0)); + Change { + median: quantile(&changes, 1, 2), + first_quartile: quantile(&changes, 1, 4), + third_quartile: quantile(&changes, 3, 4), + } + }); + Summary { change, overhead } +} + fn sorted(values: impl Iterator) -> Vec { let mut values: Vec = values.collect(); values.sort_unstable_by(f64::total_cmp); @@ -202,9 +262,8 @@ fn quantile(sorted: &[f64], numerator: usize, denominator: usize) -> f64 { sorted[sorted.len() * numerator / denominator] } -/// Launches one arm and reads the metric the suite names out of the two -/// numbers its launcher printed. -fn launch(launcher: &OsStr, mode: Option<&str>, backend: &Backend, suite: &Suite) -> f64 { +/// Launches one arm and reads both numbers its launcher prints. +fn launch(launcher: &OsStr, mode: Option<&str>, backend: &Backend, suite: &Suite) -> Measurement { let mut command = Command::new(launcher); if let Some(mode) = mode { command.arg(mode); @@ -222,10 +281,8 @@ fn launch(launcher: &OsStr, mode: Option<&str>, backend: &Backend, suite: &Suite .map(|number| number.parse().expect("unreadable launcher measurement")); let mut next = || numbers.next().expect("launcher reported too few measurements"); let [wall, typical] = [next(), next()]; - match suite.metric { - Metric::Wall => wall, - Metric::Typical => typical, - } + assert!(numbers.next().is_none(), "launcher reported too many measurements"); + Measurement { wall, typical } } fn validate(launcher: &OsStr, target: &str) { @@ -243,3 +300,27 @@ fn validate(launcher: &OsStr, target: &str) { .expect("failed to run the benchmark launcher"); assert!(status.success(), "launcher validation failed for {}", launcher.to_string_lossy()); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_suite_reports_batch_and_wall_time() { + assert_eq!(ACCESS_SUITE.reports.len(), 2); + assert!(matches!(ACCESS_SUITE.reports[0].metric, Metric::Typical)); + assert!(matches!(ACCESS_SUITE.reports[1].metric, Metric::Wall)); + + let iterations = [Iteration { + base: Measurement { wall: 100.0, typical: 20.0 }, + head: Measurement { wall: 125.0, typical: 22.0 }, + untracked: Measurement { wall: 50.0, typical: 10.0 }, + }]; + let typical = summarize(&iterations, Metric::Typical, true); + let wall = summarize(&iterations, Metric::Wall, true); + + assert!((typical.change.unwrap().median - 0.1).abs() < f64::EPSILON); + assert!((wall.change.unwrap().median - 0.25).abs() < f64::EPSILON); + assert!((wall.overhead - 1.5).abs() < f64::EPSILON); + } +} diff --git a/crates/fspy_preload_unix/src/lib.rs b/crates/fspy_preload_unix/src/lib.rs index 42bf9e9cb..6217e223e 100644 --- a/crates/fspy_preload_unix/src/lib.rs +++ b/crates/fspy_preload_unix/src/lib.rs @@ -1,6 +1,6 @@ -// 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. +// Compile as an empty crate on non-unix targets and on musl (where +// seccomp-filtered ptrace 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))] #[cfg(all(unix, not(target_env = "musl")))] diff --git a/crates/fspy_seccomp_unotify/.clippy.toml b/crates/fspy_seccomp_ptrace/.clippy.toml similarity index 100% rename from crates/fspy_seccomp_unotify/.clippy.toml rename to crates/fspy_seccomp_ptrace/.clippy.toml diff --git a/crates/fspy_seccomp_unotify/Cargo.toml b/crates/fspy_seccomp_ptrace/Cargo.toml similarity index 78% rename from crates/fspy_seccomp_unotify/Cargo.toml rename to crates/fspy_seccomp_ptrace/Cargo.toml index 915d0867c..f84b472b5 100644 --- a/crates/fspy_seccomp_unotify/Cargo.toml +++ b/crates/fspy_seccomp_ptrace/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "fspy_seccomp_unotify" +name = "fspy_seccomp_ptrace" version = "0.1.0" edition = "2024" license.workspace = true @@ -8,8 +8,8 @@ publish = false [target.'cfg(target_os = "linux")'.dependencies] wincode = { workspace = true, features = ["derive"] } libc = { workspace = true } -nix = { workspace = true, features = ["process", "fs", "poll", "socket", "uio"] } -passfd = { workspace = true, default-features = false, optional = true } +nix = { workspace = true, features = ["process", "fs", "ptrace", "uio"] } +rustc-hash = { workspace = true } seccompiler = { workspace = true } syscalls = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["net", "process", "io-util", "rt", "sync"] } @@ -25,8 +25,8 @@ test-log = { workspace = true } tokio = { workspace = true, features = ["macros", "time"] } [features] -supervisor = ["dep:passfd", "passfd/async"] -target = ["dep:passfd"] +supervisor = [] +target = [] [lints] workspace = true diff --git a/crates/fspy_seccomp_ptrace/README.md b/crates/fspy_seccomp_ptrace/README.md new file mode 100644 index 000000000..162dfcfa8 --- /dev/null +++ b/crates/fspy_seccomp_ptrace/README.md @@ -0,0 +1,6 @@ +# fspy_seccomp_ptrace + +Seccomp-filtered `ptrace` support used by `fspy` to intercept selected direct syscalls on Linux. + +- `src/supervisor` is gated by feature `supervisor`. It contains code that needs to run in the supervisor process (the process that uses `fspy` to track child processes). +- `src/target` is gated by feature `target`. It contains code that needs to run in target processes (child processes tracked by `fspy`). diff --git a/crates/fspy_seccomp_ptrace/src/bindings/mod.rs b/crates/fspy_seccomp_ptrace/src/bindings/mod.rs new file mode 100644 index 000000000..c2b156c9c --- /dev/null +++ b/crates/fspy_seccomp_ptrace/src/bindings/mod.rs @@ -0,0 +1,35 @@ +use std::os::raw::c_int; + +use libc::syscall; + +/// # Safety +/// The `args` pointer must be valid for the given `operation`, or null if the operation +/// does not require arguments. +unsafe fn seccomp( + operation: libc::c_uint, + flags: libc::c_uint, + args: *mut libc::c_void, +) -> nix::Result { + // SAFETY: caller guarantees `args` is valid for the given seccomp operation + let ret = unsafe { syscall(libc::SYS_seccomp, operation, flags, args) }; + if ret < 0 { + return Err(nix::Error::last()); + } + Ok(c_int::try_from(ret).unwrap()) +} + +/// Installs a seccomp filter for the current thread. +/// +/// # Errors +/// Returns an error if the seccomp syscall fails (e.g., invalid filter program or +/// insufficient privileges). +pub fn install_filter(prog: &[libc::sock_filter]) -> nix::Result<()> { + let mut filter = libc::sock_fprog { + len: prog.len().try_into().unwrap(), + filter: prog.as_ptr().cast_mut().cast(), + }; + + // SAFETY: `filter` is a valid `sock_fprog` pointing to the BPF program slice. + unsafe { seccomp(libc::SECCOMP_SET_MODE_FILTER, 0, (&raw mut filter).cast()) }?; + Ok(()) +} diff --git a/crates/fspy_seccomp_unotify/src/lib.rs b/crates/fspy_seccomp_ptrace/src/lib.rs similarity index 73% rename from crates/fspy_seccomp_unotify/src/lib.rs rename to crates/fspy_seccomp_ptrace/src/lib.rs index a70b0a816..88eb0d12c 100644 --- a/crates/fspy_seccomp_unotify/src/lib.rs +++ b/crates/fspy_seccomp_ptrace/src/lib.rs @@ -1,6 +1,6 @@ #![cfg(target_os = "linux")] -#[cfg(any(feature = "supervisor", feature = "target"))] +#[cfg(feature = "target")] mod bindings; pub mod payload; #[cfg(feature = "target")] diff --git a/crates/fspy_seccomp_unotify/src/payload/filter.rs b/crates/fspy_seccomp_ptrace/src/payload/filter.rs similarity index 100% rename from crates/fspy_seccomp_unotify/src/payload/filter.rs rename to crates/fspy_seccomp_ptrace/src/payload/filter.rs diff --git a/crates/fspy_seccomp_unotify/src/payload/mod.rs b/crates/fspy_seccomp_ptrace/src/payload/mod.rs similarity index 87% rename from crates/fspy_seccomp_unotify/src/payload/mod.rs rename to crates/fspy_seccomp_ptrace/src/payload/mod.rs index 6895bc55a..b028249a1 100644 --- a/crates/fspy_seccomp_unotify/src/payload/mod.rs +++ b/crates/fspy_seccomp_ptrace/src/payload/mod.rs @@ -3,7 +3,7 @@ pub use filter::Filter; use wincode::{SchemaRead, SchemaWrite}; #[derive(Debug, SchemaWrite, SchemaRead, Clone)] -pub struct SeccompPayload { +pub struct PtracePayload { pub(crate) ipc_path: Vec, pub(crate) filter: Filter, } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs b/crates/fspy_seccomp_ptrace/src/supervisor/handler/arg.rs similarity index 84% rename from crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs rename to crates/fspy_seccomp_ptrace/src/supervisor/handler/arg.rs index fa9dc305d..0b396c34a 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs +++ b/crates/fspy_seccomp_ptrace/src/supervisor/handler/arg.rs @@ -6,9 +6,11 @@ use std::{ os::{fd::RawFd, raw::c_void}, }; -use libc::{pid_t, seccomp_notif}; +use libc::pid_t; use nix::sys::uio::{RemoteIoVec, process_vm_readv}; +use super::Syscall; + pub trait FromSyscallArg: Sized { /// Converts a raw syscall argument into this type. /// @@ -185,45 +187,45 @@ impl FromSyscallArg for c_int { } } -pub trait FromNotify: Sized { - /// Parses syscall arguments from a seccomp notification. +pub trait FromSyscall: Sized { + /// Parses syscall arguments from a ptrace stop. /// /// # Errors /// Returns an error if any argument cannot be parsed. - fn from_notify(notif: &seccomp_notif) -> io::Result; + fn from_syscall(syscall: &Syscall) -> io::Result; } -impl FromNotify for (T,) { - fn from_notify(notif: &seccomp_notif) -> io::Result { - Ok((T::from_syscall_arg(notif.data.args[0])?,)) +impl FromSyscall for (T,) { + fn from_syscall(syscall: &Syscall) -> io::Result { + Ok((T::from_syscall_arg(syscall.args()[0])?,)) } } -impl FromNotify for (T1, T2) { - fn from_notify(notif: &seccomp_notif) -> io::Result { - Ok((T1::from_syscall_arg(notif.data.args[0])?, T2::from_syscall_arg(notif.data.args[1])?)) +impl FromSyscall for (T1, T2) { + fn from_syscall(syscall: &Syscall) -> io::Result { + Ok((T1::from_syscall_arg(syscall.args()[0])?, T2::from_syscall_arg(syscall.args()[1])?)) } } -impl FromNotify for (T1, T2, T3) { - fn from_notify(notif: &seccomp_notif) -> io::Result { +impl FromSyscall for (T1, T2, T3) { + fn from_syscall(syscall: &Syscall) -> io::Result { Ok(( - T1::from_syscall_arg(notif.data.args[0])?, - T2::from_syscall_arg(notif.data.args[1])?, - T3::from_syscall_arg(notif.data.args[2])?, + T1::from_syscall_arg(syscall.args()[0])?, + T2::from_syscall_arg(syscall.args()[1])?, + T3::from_syscall_arg(syscall.args()[2])?, )) } } -impl FromNotify +impl FromSyscall for (T1, T2, T3, T4) { - fn from_notify(notif: &seccomp_notif) -> io::Result { + fn from_syscall(syscall: &Syscall) -> io::Result { Ok(( - T1::from_syscall_arg(notif.data.args[0])?, - T2::from_syscall_arg(notif.data.args[1])?, - T3::from_syscall_arg(notif.data.args[2])?, - T4::from_syscall_arg(notif.data.args[3])?, + T1::from_syscall_arg(syscall.args()[0])?, + T2::from_syscall_arg(syscall.args()[1])?, + T3::from_syscall_arg(syscall.args()[2])?, + T4::from_syscall_arg(syscall.args()[3])?, )) } } diff --git a/crates/fspy_seccomp_ptrace/src/supervisor/handler/mod.rs b/crates/fspy_seccomp_ptrace/src/supervisor/handler/mod.rs new file mode 100644 index 000000000..ddc80a84c --- /dev/null +++ b/crates/fspy_seccomp_ptrace/src/supervisor/handler/mod.rs @@ -0,0 +1,113 @@ +pub mod arg; + +use std::io; + +use nix::{sys::ptrace, unistd::Pid}; + +#[derive(Debug, Clone, Copy)] +pub struct Syscall { + pid: libc::pid_t, + number: i64, + args: [u64; 6], +} + +impl Syscall { + pub(crate) fn read(pid: Pid) -> nix::Result { + let registers = ptrace::getregs(pid)?; + + #[cfg(target_arch = "x86_64")] + let (number, args) = ( + i64::from_ne_bytes(registers.orig_rax.to_ne_bytes()), + [ + registers.rdi, + registers.rsi, + registers.rdx, + registers.r10, + registers.r8, + registers.r9, + ], + ); + #[cfg(target_arch = "aarch64")] + let (number, args) = ( + i64::from_ne_bytes(registers.regs[8].to_ne_bytes()), + [ + registers.regs[0], + registers.regs[1], + registers.regs[2], + registers.regs[3], + registers.regs[4], + registers.regs[5], + ], + ); + + Ok(Self { pid: pid.as_raw(), number, args }) + } + + #[doc(hidden)] + #[must_use] + pub const fn pid(&self) -> libc::pid_t { + self.pid + } + + #[doc(hidden)] + #[must_use] + pub const fn number(&self) -> i64 { + self.number + } + + #[doc(hidden)] + #[must_use] + pub const fn args(&self) -> &[u64; 6] { + &self.args + } +} + +#[expect(clippy::module_name_repetitions, reason = "clearer as a standalone export")] +pub trait PtraceHandler { + fn syscalls() -> &'static [syscalls::Sysno]; + + /// Handles a seccomp-filtered syscall stopped by ptrace. + /// + /// # Errors + /// Returns an error if the handler fails to process the syscall. + fn handle_syscall(&mut self, syscall: &Syscall) -> io::Result<()>; +} + +#[doc(hidden)] // Re-export for use in the macro +pub use syscalls::Sysno; + +#[macro_export] +macro_rules! impl_handler { + ($type:ty: $( + $(#[$attr:meta])? + $syscall:ident, + )* ) => { + + impl $crate::supervisor::handler::PtraceHandler for $type { + fn syscalls() -> &'static [$crate::supervisor::handler::Sysno] { + &[ $( + $(#[$attr])? + $crate::supervisor::handler::Sysno::$syscall + ),* ] + } + + fn handle_syscall( + &mut self, + syscall: &$crate::supervisor::handler::Syscall, + ) -> ::std::io::Result<()> { + $crate::supervisor::handler::arg::Caller::with_pid(syscall.pid(), |caller| { + $( + $(#[$attr])? + if syscall.number() == $crate::supervisor::handler::Sysno::$syscall as i64 { + return self.$syscall( + caller, + $crate::supervisor::handler::arg::FromSyscall::from_syscall(syscall)?, + ) + } + )* + Ok(()) + }) + } + } + }; +} diff --git a/crates/fspy_seccomp_ptrace/src/supervisor/mod.rs b/crates/fspy_seccomp_ptrace/src/supervisor/mod.rs new file mode 100644 index 000000000..b09e60b74 --- /dev/null +++ b/crates/fspy_seccomp_ptrace/src/supervisor/mod.rs @@ -0,0 +1,269 @@ +pub mod handler; + +use std::{ + convert::Infallible, + io::{self, Read as _, Write as _}, + os::unix::{ffi::OsStrExt as _, net::UnixStream}, + sync::{Arc, Mutex}, +}; + +use futures_util::{ + future::{Either, select}, + pin_mut, +}; +pub use handler::PtraceHandler; +use handler::Syscall; +use nix::{ + errno::Errno, + sys::{ + ptrace::{self, Event, Options}, + signal::Signal, + wait::{WaitPidFlag, WaitStatus, waitpid}, + }, + unistd::Pid, +}; +use rustc_hash::FxHashSet; +use seccompiler::{BpfProgram, SeccompAction, SeccompFilter}; +use tokio::{net::UnixListener, sync::oneshot, task::JoinHandle}; +use tracing::{Level, span}; + +use crate::payload::{Filter, PtracePayload}; + +pub struct Supervisor { + payload: PtracePayload, + cancel_tx: oneshot::Sender, + accept_loop_task: JoinHandle>, + handler: Arc>, + trace_error: Arc>>, +} + +impl Supervisor { + #[must_use] + pub const fn payload(&self) -> &PtracePayload { + &self.payload + } + + /// Stops the supervisor and returns all handler instances. + /// + /// # Panics + /// Panics if the accept loop task has panicked or an internal mutex was poisoned. + /// + /// # Errors + /// Returns an error if the accept loop or a tracer thread failed with an I/O error. + pub async fn stop(self) -> io::Result> { + drop(self.cancel_tx); + self.accept_loop_task.await.expect("accept loop task panicked")?; + let trace_error = self.trace_error.lock().expect("trace error mutex poisoned").take(); + if let Some(error) = trace_error { + return Err(error); + } + // Tracer threads are intentionally detached. A descendant may outlive the + // process fspy was asked to wait for, but detaching it would make every + // SECCOMP_RET_TRACE syscall fail with ENOSYS. Snapshot the accesses seen + // through the root's exit and let detached threads service descendants + // into the fresh default handler until those descendants exit. + let handler = + std::mem::take(&mut *self.handler.lock().expect("ptrace handler mutex poisoned")); + Ok(vec![handler]) + } +} + +/// Creates a new supervisor that traces only the syscalls selected by its seccomp filter. +/// +/// # Panics +/// Panics if the seccomp filter cannot be compiled or the target architecture is unsupported. +/// +/// # Errors +/// Returns an error if the temporary IPC socket cannot be created. +pub fn supervise() -> io::Result> { + let attach_listener = tempfile::Builder::new() + .prefix("fspy_seccomp_ptrace") + .make(|path| UnixListener::bind(path))?; + + let seccomp_filter = SeccompFilter::new( + H::syscalls().iter().map(|sysno| (sysno.id().into(), vec![])).collect(), + SeccompAction::Allow, + SeccompAction::Trace(0), + std::env::consts::ARCH.try_into().unwrap(), + ) + .unwrap(); + + let bpf_filter = + Filter(BpfProgram::try_from(seccomp_filter).unwrap().into_iter().map(Into::into).collect()); + + let payload = PtracePayload { + ipc_path: attach_listener.path().as_os_str().as_bytes().to_vec(), + filter: bpf_filter, + }; + + let (cancel_tx, mut cancel_rx) = oneshot::channel::(); + let handler = Arc::new(Mutex::new(H::default())); + let accept_handler = Arc::clone(&handler); + let trace_error = Arc::new(Mutex::new(None)); + let accept_trace_error = Arc::clone(&trace_error); + + let accept_loop = async move { + loop { + let accept_future = attach_listener.as_file().accept(); + pin_mut!(accept_future); + let (incoming_stream, _) = match select(&mut cancel_rx, accept_future).await { + Either::Left((Err(_), _)) => break, + Either::Right((incoming, _)) => incoming?, + }; + let incoming_stream = incoming_stream.into_std()?; + incoming_stream.set_nonblocking(false)?; + let thread_handler = Arc::clone(&accept_handler); + let thread_trace_error = Arc::clone(&accept_trace_error); + // ptrace ownership is thread-specific, so the thread that seizes this + // root must also wait for and resume every event in its process tree. + std::thread::Builder::new().name("fspy-ptrace".into()).spawn(move || { + if let Err(error) = trace(incoming_stream, &thread_handler) { + let mut trace_error = + thread_trace_error.lock().expect("trace error mutex poisoned"); + if trace_error.is_none() { + *trace_error = Some(error); + } + } + })?; + } + Ok(()) + }; + + Ok(Supervisor { + payload, + cancel_tx, + accept_loop_task: tokio::spawn(accept_loop), + handler, + trace_error, + }) +} + +fn trace(mut stream: UnixStream, handler: &Mutex) -> io::Result<()> { + let mut tid_bytes = [0; std::mem::size_of::()]; + stream.read_exact(&mut tid_bytes)?; + let root = Pid::from_raw(libc::pid_t::from_ne_bytes(tid_bytes)); + + let options = Options::PTRACE_O_TRACESECCOMP + | Options::PTRACE_O_TRACEFORK + | Options::PTRACE_O_TRACEVFORK + | Options::PTRACE_O_TRACECLONE + | Options::PTRACE_O_TRACEEXEC + | Options::PTRACE_O_TRACEEXIT; + let attach_result = ptrace::seize(root, options); + let attach_errno = attach_result.as_ref().err().map_or(0, |error| *error as libc::c_int); + stream.write_all(&attach_errno.to_ne_bytes())?; + attach_result?; + drop(stream); + + let mut tracees = FxHashSet::from_iter([root]); + while !tracees.is_empty() { + let status = match waitpid(None, Some(WaitPidFlag::__WALL | WaitPidFlag::__WNOTHREAD)) { + Ok(status) => status, + Err(Errno::EINTR) => continue, + Err(error) => return Err(error.into()), + }; + handle_status(status, &mut tracees, handler)?; + } + + Ok(()) +} + +fn handle_status( + status: WaitStatus, + tracees: &mut FxHashSet, + handler: &Mutex, +) -> io::Result<()> { + match status { + WaitStatus::Exited(pid, _) | WaitStatus::Signaled(pid, _, _) => { + tracees.remove(&pid); + } + WaitStatus::PtraceEvent(pid, Signal::SIGTRAP, event) + if event == Event::PTRACE_EVENT_SECCOMP as libc::c_int => + { + let _span = span!(Level::TRACE, "seccomp ptrace tick"); + if let Ok(syscall) = Syscall::read(pid) { + let _handle_result = + handler.lock().expect("ptrace handler mutex poisoned").handle_syscall(&syscall); + } + continue_tracee(pid, None)?; + } + WaitStatus::PtraceEvent(pid, Signal::SIGTRAP, event) + if event == Event::PTRACE_EVENT_FORK as libc::c_int + || event == Event::PTRACE_EVENT_VFORK as libc::c_int + || event == Event::PTRACE_EVENT_CLONE as libc::c_int => + { + let new_pid = Pid::from_raw( + ptrace::getevent(pid)? + .try_into() + .map_err(|_| io::Error::other("ptrace child PID does not fit pid_t"))?, + ); + tracees.insert(new_pid); + continue_tracee(pid, None)?; + } + WaitStatus::PtraceEvent(pid, Signal::SIGTRAP, event) + if event == Event::PTRACE_EVENT_EXEC as libc::c_int => + { + let old_pid = Pid::from_raw( + ptrace::getevent(pid)? + .try_into() + .map_err(|_| io::Error::other("ptrace exec TID does not fit pid_t"))?, + ); + tracees.remove(&old_pid); + tracees.insert(pid); + continue_tracee(pid, None)?; + } + WaitStatus::PtraceEvent(pid, Signal::SIGTRAP, event) + if event == Event::PTRACE_EVENT_EXIT as libc::c_int => + { + tracees.remove(&pid); + detach_tracee(pid)?; + } + WaitStatus::PtraceEvent(pid, signal, event) + if event == Event::PTRACE_EVENT_STOP as libc::c_int => + { + if signal == Signal::SIGTRAP { + continue_tracee(pid, None)?; + } else { + listen(pid)?; + } + } + WaitStatus::PtraceEvent(pid, _, _) | WaitStatus::PtraceSyscall(pid) => { + continue_tracee(pid, None)?; + } + WaitStatus::Stopped(pid, signal) => { + continue_tracee(pid, signal)?; + } + WaitStatus::Continued(_) | WaitStatus::StillAlive => {} + } + Ok(()) +} + +fn continue_tracee(pid: Pid, signal: impl Into>) -> io::Result<()> { + match ptrace::cont(pid, signal) { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn detach_tracee(pid: Pid) -> io::Result<()> { + // Detach at PTRACE_EVENT_EXIT so the tracee's original parent, rather than + // this tracer thread, consumes the terminal wait status. + match ptrace::detach(pid, None) { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn listen(pid: Pid) -> io::Result<()> { + // SAFETY: PTRACE_LISTEN takes no address or data argument, and `pid` is a + // seized tracee currently stopped at PTRACE_EVENT_STOP. + let result = unsafe { + libc::ptrace( + libc::PTRACE_LISTEN, + pid.as_raw(), + std::ptr::null_mut::(), + std::ptr::null_mut::(), + ) + }; + Errno::result(result).map(drop).map_err(Into::into) +} diff --git a/crates/fspy_seccomp_ptrace/src/target.rs b/crates/fspy_seccomp_ptrace/src/target.rs new file mode 100644 index 000000000..387a2c5a0 --- /dev/null +++ b/crates/fspy_seccomp_ptrace/src/target.rs @@ -0,0 +1,41 @@ +use std::{ + ffi::OsStr, + io::{Read as _, Write as _}, + os::unix::{ffi::OsStrExt, net::UnixStream}, +}; + +use libc::sock_filter; +use nix::sys::prctl::set_no_new_privs; + +use crate::{bindings::install_filter, payload::PtracePayload}; + +/// Attaches the current thread to the supervisor and installs its seccomp trace filter. +/// +/// # Errors +/// Returns an error if the supervisor cannot attach, setting no-new-privs fails, +/// the filter cannot be installed, or the IPC socket communication fails. +pub fn install_target(payload: &PtracePayload) -> nix::Result<()> { + let ipc_path = OsStr::from_bytes(&payload.ipc_path); + let mut stream = UnixStream::connect(ipc_path).map_err(io_error_to_errno)?; + // SAFETY: gettid takes no arguments and returns the caller's Linux thread ID. + #[expect(clippy::cast_possible_truncation, reason = "Linux thread IDs use pid_t")] + let tid = unsafe { libc::syscall(libc::SYS_gettid) } as libc::pid_t; + stream.write_all(&tid.to_ne_bytes()).map_err(io_error_to_errno)?; + + let mut response = [0; std::mem::size_of::()]; + stream.read_exact(&mut response).map_err(io_error_to_errno)?; + let attach_errno = libc::c_int::from_ne_bytes(response); + if attach_errno != 0 { + return Err(nix::Error::from_raw(attach_errno)); + } + + set_no_new_privs()?; + let sock_filters = + payload.filter.0.iter().copied().map(sock_filter::from).collect::>(); + install_filter(&sock_filters)?; + Ok(()) +} + +fn io_error_to_errno(error: std::io::Error) -> nix::Error { + nix::Error::try_from(error).unwrap_or(nix::Error::UnknownErrno) +} diff --git a/crates/fspy_seccomp_unotify/tests/arg_types.rs b/crates/fspy_seccomp_ptrace/tests/arg_types.rs similarity index 65% rename from crates/fspy_seccomp_unotify/tests/arg_types.rs rename to crates/fspy_seccomp_ptrace/tests/arg_types.rs index 93c1c9740..0de90a863 100644 --- a/crates/fspy_seccomp_unotify/tests/arg_types.rs +++ b/crates/fspy_seccomp_ptrace/tests/arg_types.rs @@ -5,11 +5,12 @@ use std::{ ffi::{CString, OsString}, io, os::unix::ffi::OsStringExt, + process::ExitStatus, time::Duration, }; use assertables::assert_contains; -use fspy_seccomp_unotify::{ +use fspy_seccomp_ptrace::{ impl_handler, supervisor::{ handler::arg::{CStrPtr, Caller, Fd}, @@ -19,7 +20,8 @@ use fspy_seccomp_unotify::{ }; use nix::{ fcntl::{AT_FDCWD, OFlag, openat}, - sys::stat::Mode, + sys::{stat::Mode, wait::waitpid}, + unistd::{ForkResult, fork}, }; use test_log::test; use tokio::{process::Command, task::spawn_blocking, time::timeout}; @@ -47,27 +49,35 @@ impl SyscallRecorder { impl_handler!(SyscallRecorder: openat,); async fn run_in_pre_exec( - mut f: impl FnMut() -> io::Result<()> + Send + Sync + 'static, + f: impl FnMut() -> io::Result<()> + Send + Sync + 'static, ) -> Result, Box> { + let (exit_status, syscalls) = run_command(Command::new("/bin/echo"), f).await?; + assert!(exit_status.success()); + Ok(syscalls) +} + +async fn run_command( + mut command: Command, + mut after_install: impl FnMut() -> io::Result<()> + Send + Sync + 'static, +) -> Result<(ExitStatus, Vec), Box> { Ok(timeout(Duration::from_secs(5), async move { - let mut cmd = Command::new("/bin/echo"); let supervisor = supervise::()?; let payload = supervisor.payload().clone(); // SAFETY: `pre_exec` closure runs in the forked child process before exec. - // It installs the seccomp filter and runs the user-provided closure, both of - // which are safe in a pre-exec context (no async, no locks held). + // It attaches to the ptrace supervisor, installs the seccomp filter, and + // runs the user-provided closure. None of these operations use async state. unsafe { - cmd.pre_exec(move || { + command.pre_exec(move || { install_target(&payload)?; - f()?; + after_install()?; Ok(()) }); } let child_fut = spawn_blocking(move || { let _span = span!(Level::TRACE, "spawn test child process"); - cmd.spawn() + command.spawn() }); let exit_status = child_fut.await.unwrap()?.wait().await?; @@ -75,13 +85,11 @@ async fn run_in_pre_exec( trace!("waiting for handler to finish and test child process to exit"); - assert!(exit_status.success()); - let recorders = supervisor.stop().await?; trace!("{} recorders awaited", recorders.len()); let syscalls = recorders.into_iter().flat_map(|recorder| recorder.0); - io::Result::Ok(syscalls.collect()) + io::Result::Ok((exit_status, syscalls.collect())) }) .await??) } @@ -142,3 +150,40 @@ async fn path_overflow() -> Result<(), Box> { ); Ok(()) } + +#[tokio::test] +async fn follows_forked_processes() -> Result<(), Box> { + let syscalls = run_in_pre_exec(|| { + // SAFETY: This closure already runs after `Command` has forked and before + // exec. Both branches use only syscall wrappers before exiting or execing. + match unsafe { fork()? } { + ForkResult::Parent { child } => { + waitpid(child, None)?; + } + ForkResult::Child => { + let _ = openat(AT_FDCWD, c"/fspy-ptrace-child", OFlag::O_RDONLY, Mode::empty()); + // SAFETY: Exiting directly avoids running parent-side destructors + // in the forked child. + unsafe { libc::_exit(0) }; + } + } + Ok(()) + }) + .await?; + assert!(syscalls.iter().any(|syscall| { + matches!( + syscall, + Syscall::Openat { path: Some(path), .. } if path == "/fspy-ptrace-child" + ) + })); + Ok(()) +} + +#[tokio::test] +async fn forwards_signals() -> Result<(), Box> { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "trap 'exit 23' USR1; kill -USR1 $$; exit 0"]); + let (exit_status, _) = run_command(command, || Ok(())).await?; + assert_eq!(exit_status.code(), Some(23)); + Ok(()) +} diff --git a/crates/fspy_seccomp_unotify/README.md b/crates/fspy_seccomp_unotify/README.md deleted file mode 100644 index c55f5ba7f..000000000 --- a/crates/fspy_seccomp_unotify/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# fspy_seccomp_unotify - -Safe bindings for `seccomp_unotify` used by `fspy` to intercept direct syscalls on Linux. - -- `src/supervisor` is gated by feature `supervisor`. It contains code that needs to run in the supervisor process(the process that uses `fspy` to track child processes). -- `src/target` is gated by feature `target`. It contains code that needs to run in target processes(child processes tracked by `fspy`). diff --git a/crates/fspy_seccomp_unotify/src/bindings/alloc.rs b/crates/fspy_seccomp_unotify/src/bindings/alloc.rs deleted file mode 100644 index 024287dea..000000000 --- a/crates/fspy_seccomp_unotify/src/bindings/alloc.rs +++ /dev/null @@ -1,106 +0,0 @@ -use std::{ - alloc::{self, Layout}, - cmp::max, - ops::Deref, - ptr::NonNull, - sync::LazyLock, -}; - -use super::get_notif_sizes; - -#[derive(Debug)] -struct BufSizes { - req_layout: Layout, - resp_layout: Layout, -} - -static BUF_SIZES: LazyLock = LazyLock::new(|| { - const MAX_ALIGN: usize = align_of::(); - - let sizes = get_notif_sizes().unwrap(); - BufSizes { - req_layout: Layout::from_size_align( - max(sizes.seccomp_notif.into(), size_of::()), - MAX_ALIGN, - ) - .unwrap(), - resp_layout: Layout::from_size_align( - max(sizes.seccomp_notif_resp.into(), size_of::()), - MAX_ALIGN, - ) - .unwrap(), - } -}); - -pub struct Alloced { - ptr: NonNull, - layout: Layout, -} - -impl Alloced { - /// Allocates a zero-initialized buffer with the given layout. - /// - /// # Safety - /// The `layout` must have a size large enough to hold a value of type `T` and - /// must have proper alignment for `T`. - pub(crate) unsafe fn alloc(layout: Layout) -> Self { - // SAFETY: layout is non-zero-sized (guaranteed by caller) and properly aligned - let ptr = unsafe { alloc::alloc_zeroed(layout) }; - - let ptr = NonNull::new(ptr).unwrap(); - Self { ptr: ptr.cast(), layout } - } - - pub(crate) const fn zeroed(&mut self) -> &mut T { - // SAFETY: `self.ptr` was allocated with `self.layout.size()` bytes, - // so writing that many zero bytes is within bounds - unsafe { self.ptr.cast::().write_bytes(0, self.layout.size()) }; - // SAFETY: the pointer is valid, properly aligned, and the buffer has just - // been zero-initialized, which is valid for the kernel structs used here - unsafe { self.ptr.as_mut() } - } -} - -impl Deref for Alloced { - type Target = T; - - fn deref(&self) -> &Self::Target { - // SAFETY: the pointer is valid and properly aligned, allocated in `alloc()` - unsafe { self.ptr.as_ref() } - } -} - -impl Drop for Alloced { - fn drop(&mut self) { - // SAFETY: `self.ptr` was allocated with `alloc::alloc_zeroed` using `self.layout`, - // so it is safe to deallocate with the same layout - unsafe { - alloc::dealloc(self.ptr.as_ptr().cast(), self.layout); - } - } -} - -// SAFETY: `Alloced` owns a heap allocation and does not use thread-local storage. -// It is safe to send across threads when `T` itself is `Send + Sync`. -unsafe impl Send for Alloced {} -// SAFETY: `Alloced` only provides shared access via `Deref`, which is safe -// when `T` is `Send + Sync`. -unsafe impl Sync for Alloced {} - -/// Allocates a zero-initialized buffer for a `seccomp_notif` struct, sized to at least -/// what the kernel requires. -#[must_use] -pub fn alloc_seccomp_notif() -> Alloced { - // SAFETY: `BUF_SIZES.req_layout` is computed from `get_notif_sizes()` and - // `size_of::()`, guaranteeing sufficient size and alignment - unsafe { Alloced::alloc(BUF_SIZES.req_layout) } -} - -/// Allocates a zero-initialized buffer for a `seccomp_notif_resp` struct, sized to at least -/// what the kernel requires. -#[must_use] -pub fn alloc_seccomp_notif_resp() -> Alloced { - // SAFETY: `BUF_SIZES.resp_layout` is computed from `get_notif_sizes()` and - // `size_of::()`, guaranteeing sufficient size and alignment - unsafe { Alloced::alloc(BUF_SIZES.resp_layout) } -} diff --git a/crates/fspy_seccomp_unotify/src/bindings/mod.rs b/crates/fspy_seccomp_unotify/src/bindings/mod.rs deleted file mode 100644 index b6461a4f4..000000000 --- a/crates/fspy_seccomp_unotify/src/bindings/mod.rs +++ /dev/null @@ -1,87 +0,0 @@ -#[cfg(feature = "supervisor")] -pub mod alloc; - -#[cfg(feature = "supervisor")] -use alloc::Alloced; -use std::os::raw::c_int; - -use libc::syscall; - -/// # Safety -/// The `args` pointer must be valid for the given `operation`, or null if the operation -/// does not require arguments. -unsafe fn seccomp( - operation: libc::c_uint, - flags: libc::c_uint, - args: *mut libc::c_void, -) -> nix::Result { - // SAFETY: caller guarantees `args` is valid for the given seccomp operation - let ret = unsafe { syscall(libc::SYS_seccomp, operation, flags, args) }; - if ret < 0 { - return Err(nix::Error::last()); - } - Ok(c_int::try_from(ret).unwrap()) -} - -#[cfg(feature = "supervisor")] -fn get_notif_sizes() -> nix::Result { - use std::mem::zeroed; - // SAFETY: `seccomp_notif_sizes` is a plain data struct safe to zero-initialize - let mut sizes = unsafe { zeroed::() }; - // SAFETY: `sizes` is a valid mutable pointer to a `seccomp_notif_sizes` struct, - // which is the expected argument for `SECCOMP_GET_NOTIF_SIZES` - unsafe { seccomp(libc::SECCOMP_GET_NOTIF_SIZES, 0, (&raw mut sizes).cast()) }?; - Ok(sizes) -} - -/// Receives a seccomp notification from the given file descriptor into the provided buffer. -/// -/// # Errors -/// Returns an error if the ioctl call fails (e.g., the fd is invalid or the kernel -/// returns an error). -#[cfg(feature = "supervisor")] -pub fn notif_recv( - fd: std::os::fd::BorrowedFd<'_>, - notif_buf: &mut Alloced, -) -> nix::Result<()> { - use std::os::fd::AsRawFd; - const SECCOMP_IOCTL_NOTIF_RECV: libc::Ioctl = 3_226_476_800u64 as libc::Ioctl; - // SAFETY: `notif_buf.zeroed()` returns a valid mutable pointer to a zeroed - // `seccomp_notif` buffer with sufficient size for the kernel's notification struct - let ret = unsafe { - libc::ioctl(fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_RECV, (&raw mut *notif_buf.zeroed())) - }; - if ret < 0 { - return Err(nix::Error::last()); - } - Ok(()) -} - -/// Installs a seccomp user notification filter and returns the notification file descriptor. -/// -/// # Errors -/// Returns an error if the seccomp syscall fails (e.g., invalid filter program or -/// insufficient privileges). -#[cfg(feature = "target")] -pub fn install_unotify_filter(prog: &[libc::sock_filter]) -> nix::Result { - use std::os::fd::FromRawFd; - let mut filter = libc::sock_fprog { - len: prog.len().try_into().unwrap(), - filter: prog.as_ptr().cast_mut().cast(), - }; - - // SAFETY: `filter` is a valid `sock_fprog` pointing to the BPF program slice, - // and `SECCOMP_FILTER_FLAG_NEW_LISTENER` requests a notification fd - #[expect(clippy::cast_possible_truncation, reason = "flag value fits in u32")] - let fd = unsafe { - seccomp( - libc::SECCOMP_SET_MODE_FILTER, - libc::SECCOMP_FILTER_FLAG_NEW_LISTENER as _, - (&raw mut filter).cast(), - ) - }?; - - // SAFETY: the seccomp syscall with `SECCOMP_FILTER_FLAG_NEW_LISTENER` returns - // a valid, owned file descriptor on success - Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }) -} diff --git a/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs b/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs deleted file mode 100644 index 52151c298..000000000 --- a/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -pub mod arg; - -use std::io; - -use libc::seccomp_notif; - -#[expect(clippy::module_name_repetitions, reason = "clearer as a standalone export")] -pub trait SeccompNotifyHandler { - fn syscalls() -> &'static [syscalls::Sysno]; - /// Handles a seccomp notification for an intercepted syscall. - /// - /// # Errors - /// Returns an error if the handler fails to process the notification. - fn handle_notify(&mut self, notify: &seccomp_notif) -> io::Result<()>; -} - -#[doc(hidden)] // Re-export for use in the macro -pub use syscalls::Sysno; - -#[macro_export] -macro_rules! impl_handler { - ($type:ty: $( - $(#[$attr:meta])? - $syscall:ident, - )* ) => { - - impl $crate::supervisor::handler::SeccompNotifyHandler for $type { - fn syscalls() -> &'static [$crate::supervisor::handler::Sysno] { - &[ $( - $(#[$attr])? - $crate::supervisor::handler::Sysno::$syscall - ),* ] - } - fn handle_notify(&mut self, notify: &::libc::seccomp_notif) -> ::std::io::Result<()> { - $crate::supervisor::handler::arg::Caller::with_pid(notify.pid as _, |caller| { - $( - $(#[$attr])? - if notify.data.nr == $crate::supervisor::handler::Sysno::$syscall as _ { - return self.$syscall(caller, $crate::supervisor::handler::arg::FromNotify::from_notify(notify)?) - } - )* - Ok(()) - }) - } - } - }; -} diff --git a/crates/fspy_seccomp_unotify/src/supervisor/listener.rs b/crates/fspy_seccomp_unotify/src/supervisor/listener.rs deleted file mode 100644 index 0de6f89f5..000000000 --- a/crates/fspy_seccomp_unotify/src/supervisor/listener.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::{ - io, - os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}, -}; - -use libc::{seccomp_notif, seccomp_notif_resp}; -use tokio::io::unix::AsyncFd; -use tracing::trace; - -use crate::bindings::{ - alloc::{Alloced, alloc_seccomp_notif}, - notif_recv, -}; - -pub struct NotifyListener { - async_fd: AsyncFd, - notif_buf: Alloced, -} - -impl TryFrom for NotifyListener { - type Error = io::Error; - - fn try_from(value: OwnedFd) -> Result { - Ok(Self { async_fd: AsyncFd::new(value)?, notif_buf: alloc_seccomp_notif() }) - } -} -impl AsFd for NotifyListener { - fn as_fd(&self) -> BorrowedFd<'_> { - self.async_fd.as_fd() - } -} - -const SECCOMP_IOCTL_NOTIF_SEND: libc::Ioctl = 3_222_806_785u64 as libc::Ioctl; - -impl NotifyListener { - /// Sends a `SECCOMP_USER_NOTIF_FLAG_CONTINUE` response for the given request ID. - /// - /// # Errors - /// Returns an error if the ioctl call fails, except for `ENOENT` which is - /// silently ignored (indicates the target process's syscall was interrupted). - pub fn send_continue( - &self, - req_id: u64, - buf: &mut Alloced, - ) -> io::Result<()> { - let resp = buf.zeroed(); - resp.id = req_id; - #[expect(clippy::cast_possible_truncation, reason = "flag constant fits in u32")] - { - resp.flags = libc::SECCOMP_USER_NOTIF_FLAG_CONTINUE as u32; - } - - // SAFETY: `resp` is a valid mutable pointer to a zeroed and populated - // `seccomp_notif_resp` buffer, and the fd is a valid seccomp notify fd - let ret = unsafe { - libc::ioctl(self.async_fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_SEND, &raw mut *resp) - }; - if ret < 0 { - let err = nix::Error::last(); - // ignore error if target process's syscall was interrupted - if err == nix::Error::ENOENT { - return Ok(()); - } - return Err(err.into()); - } - Ok(()) - } - - /// Waits for and returns the next seccomp notification, or `None` if the fd is closed. - /// - /// # Errors - /// Returns an error if waiting on or reading from the notification fd fails. - pub async fn next(&mut self) -> io::Result> { - loop { - let mut ready_guard = self.async_fd.readable().await?; - let ready = ready_guard.ready(); - trace!("notify fd readable: {:?}", ready); - if ready.is_read_closed() || ready.is_write_closed() { - return Ok(None); - } - - if !ready.is_readable() { - continue; - } - // TODO: check why this call solves the issue that `is_read_closed || is_write_closed` is never true. - ready_guard.clear_ready(); - - match notif_recv(ready_guard.get_inner().as_fd(), &mut self.notif_buf) { - Ok(()) => return Ok(Some(&self.notif_buf)), - Err(nix::Error::EINTR | nix::Error::EWOULDBLOCK | nix::Error::ENOENT) => {} - Err(other_error) => return Err(other_error.into()), - } - } - } -} diff --git a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs b/crates/fspy_seccomp_unotify/src/supervisor/mod.rs deleted file mode 100644 index b1aa0eb62..000000000 --- a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs +++ /dev/null @@ -1,129 +0,0 @@ -pub mod handler; -mod listener; - -use std::{ - convert::Infallible, - io::{self}, - os::{ - fd::{FromRawFd, OwnedFd}, - unix::ffi::OsStrExt, - }, -}; - -use futures_util::{ - future::{Either, select}, - pin_mut, -}; -pub use handler::SeccompNotifyHandler; -use listener::NotifyListener; -use passfd::tokio::FdPassingExt; -use seccompiler::{BpfProgram, SeccompAction, SeccompFilter}; -use tokio::{ - net::UnixListener, - sync::oneshot, - task::{JoinHandle, JoinSet}, -}; -use tracing::{Level, span}; - -use crate::{ - bindings::alloc::alloc_seccomp_notif_resp, - payload::{Filter, SeccompPayload}, -}; - -pub struct Supervisor { - payload: SeccompPayload, - cancel_tx: oneshot::Sender, - handling_loop_task: JoinHandle>>, -} - -impl Supervisor { - #[must_use] - pub const fn payload(&self) -> &SeccompPayload { - &self.payload - } - - /// Stops the supervisor and returns all handler instances. - /// - /// # Panics - /// Panics if the handling loop task has panicked. - /// - /// # Errors - /// Returns an error if any of the spawned handler tasks failed with an I/O error. - pub async fn stop(self) -> io::Result> { - drop(self.cancel_tx); - self.handling_loop_task.await.expect("handling loop task panicked") - } -} - -/// Creates a new supervisor that listens for seccomp user notifications. -/// -/// # Panics -/// Panics if the seccomp filter cannot be compiled or the target architecture is unsupported. -/// -/// # Errors -/// Returns an error if the temporary IPC socket cannot be created. -pub fn supervise() -> io::Result> -{ - let notify_listener = tempfile::Builder::new() - .prefix("fspy_seccomp_notify") - .make(|path| UnixListener::bind(path))?; - - let seccomp_filter = SeccompFilter::new( - H::syscalls().iter().map(|sysno| (sysno.id().into(), vec![])).collect(), - SeccompAction::Allow, - SeccompAction::UserNotif, - std::env::consts::ARCH.try_into().unwrap(), - ) - .unwrap(); - - let bpf_filter = - Filter(BpfProgram::try_from(seccomp_filter).unwrap().into_iter().map(Into::into).collect()); - - let payload = SeccompPayload { - ipc_path: notify_listener.path().as_os_str().as_bytes().to_vec(), - filter: bpf_filter, - }; - - // The oneshot channel is used to cancel the accept loop. - // The sender doesn't need to actually send anything. Drop is enough. - let (cancel_tx, mut cancel_rx) = oneshot::channel::(); - - let handling_loop = async move { - let mut join_set: JoinSet> = JoinSet::new(); - - loop { - let accept_future = notify_listener.as_file().accept(); - pin_mut!(accept_future); - let (incoming_stream, _) = match select(&mut cancel_rx, accept_future).await { - Either::Left((Err(_), _)) => break, - Either::Right((incoming, _)) => incoming?, - }; - let notify_fd = incoming_stream.recv_fd().await?; - // SAFETY: `recv_fd` returns a valid file descriptor received via - // Unix domain socket fd passing - let notify_fd = unsafe { OwnedFd::from_raw_fd(notify_fd) }; - let mut listener = NotifyListener::try_from(notify_fd)?; - - let mut handler = H::default(); - let mut resp_buf = alloc_seccomp_notif_resp(); - - join_set.spawn(async move { - while let Some(notify) = listener.next().await? { - let _span = span!(Level::TRACE, "notify loop tick"); - // Errors on the supervisor side could be caused by a target process aborting. - // It shouldn't break the syscall handling loop as there might be target processes. - let _handle_result = handler.handle_notify(notify); - let req_id = notify.id; - listener.send_continue(req_id, &mut resp_buf)?; - } - io::Result::Ok(handler) - }); - } - let mut handlers = Vec::::new(); - while let Some(handler) = join_set.join_next().await.transpose()? { - handlers.push(handler?); - } - Ok(handlers) - }; - Ok(Supervisor { payload, cancel_tx, handling_loop_task: tokio::spawn(handling_loop) }) -} diff --git a/crates/fspy_seccomp_unotify/src/target.rs b/crates/fspy_seccomp_unotify/src/target.rs deleted file mode 100644 index 5406f7961..000000000 --- a/crates/fspy_seccomp_unotify/src/target.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::{ - ffi::OsStr, - os::{ - fd::AsRawFd, - unix::{ffi::OsStrExt, net::UnixStream}, - }, -}; - -use libc::sock_filter; -use nix::sys::prctl::set_no_new_privs; -use passfd::FdPassingExt; - -use crate::{bindings::install_unotify_filter, payload::SeccompPayload}; - -/// Installs the seccomp user notification filter and sends the notification fd -/// to the supervisor via the IPC socket. -/// -/// # Errors -/// Returns an error if setting no-new-privs fails, the filter cannot be installed, -/// or the IPC socket communication fails. -pub fn install_target(payload: &SeccompPayload) -> nix::Result<()> { - set_no_new_privs()?; - let sock_filters = - payload.filter.0.iter().copied().map(sock_filter::from).collect::>(); - let notify_fd = install_unotify_filter(&sock_filters)?; - let ipc_path = OsStr::from_bytes(&payload.ipc_path); - let ipc_unix_stream = UnixStream::connect(ipc_path) - .map_err(|err| nix::Error::try_from(err).unwrap_or(nix::Error::UnknownErrno))?; - ipc_unix_stream - .send_fd(notify_fd.as_raw_fd()) - .map_err(|err| nix::Error::try_from(err).unwrap_or(nix::Error::UnknownErrno))?; - Ok(()) -} diff --git a/crates/fspy_shared_unix/Cargo.toml b/crates/fspy_shared_unix/Cargo.toml index 38b9301bb..1efb471ac 100644 --- a/crates/fspy_shared_unix/Cargo.toml +++ b/crates/fspy_shared_unix/Cargo.toml @@ -18,7 +18,7 @@ stackalloc = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] elf = { workspace = true } -fspy_seccomp_unotify = { workspace = true, features = ["target"] } +fspy_seccomp_ptrace = { 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..886784304 100644 --- a/crates/fspy_shared_unix/src/payload.rs +++ b/crates/fspy_shared_unix/src/payload.rs @@ -24,7 +24,7 @@ pub struct Payload { not(target_env = "musl"), expect(clippy::struct_field_names, reason = "descriptive field name for clarity") )] - pub seccomp_payload: fspy_seccomp_unotify::payload::SeccompPayload, + pub ptrace_payload: fspy_seccomp_ptrace::payload::PtracePayload, } #[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..54f57a6a4 100644 --- a/crates/fspy_shared_unix/src/spawn/linux/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/linux/mod.rs @@ -1,7 +1,7 @@ #[cfg(not(target_env = "musl"))] use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _, path::Path}; -use fspy_seccomp_unotify::{payload::SeccompPayload, target::install_target}; +use fspy_seccomp_ptrace::{payload::PtracePayload, target::install_target}; #[cfg(not(target_env = "musl"))] use memmap2::Mmap; @@ -18,9 +18,9 @@ use crate::{ const LD_PRELOAD: &str = "LD_PRELOAD"; -pub struct PreExec(SeccompPayload); +pub struct PreExec(PtracePayload); impl PreExec { - /// Installs the seccomp unotify filter for the current process. + /// Attaches the current thread to the ptrace supervisor and installs its seccomp filter. /// /// # Errors /// @@ -59,5 +59,5 @@ pub fn handle_exec( } command.envs.retain(|(name, _)| name != LD_PRELOAD && name != PAYLOAD_ENV_NAME); - Ok(Some(PreExec(encoded_payload.payload.seccomp_payload.clone()))) + Ok(Some(PreExec(encoded_payload.payload.ptrace_payload.clone()))) } diff --git a/crates/vite_powershell/src/lib.rs b/crates/vite_powershell/src/lib.rs index 2819cdf35..9c167c0f0 100644 --- a/crates/vite_powershell/src/lib.rs +++ b/crates/vite_powershell/src/lib.rs @@ -27,10 +27,11 @@ use vite_path::{AbsolutePath, AbsolutePathBuf}; pub const POWERSHELL_PREFIX: &[&str] = &["-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File"]; -/// Cached location of the `PowerShell` host. Prefers cross-platform -/// `pwsh.exe` when present, falling back to the Windows built-in -/// `powershell.exe`. Returns `None` on non-Windows or when neither host -/// is on `PATH`. +/// Cached location of the `PowerShell` host. +/// +/// Prefers cross-platform `pwsh.exe` when present, falling back to the +/// Windows built-in `powershell.exe`. Returns `None` on non-Windows or +/// when neither host is on `PATH`. /// /// Cached as `Arc` so callers that want shared ownership /// (e.g. `vite_task_plan`'s plan-time rewrite) can do `Arc::clone(host)` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml index da1855fe7..68e72ad64 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/preexisting_ld_preload/snapshots.toml @@ -1,7 +1,7 @@ [[e2e]] name = "preexisting_ld_preload" # Requires fspy's LD_PRELOAD injection path, which is only active on -# glibc-Linux. On musl fspy uses seccomp-unotify instead and strips +# glibc-Linux. On musl fspy uses seccomp-filtered ptrace instead and strips # LD_PRELOAD from spawned children, so the fixture's interposer-chain # assumptions don't hold. platform = "linux-gnu" diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 14ef99228..f7da00501 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -670,7 +670,7 @@ fn main() { "macos" => cfg!(target_os = "macos"), // fspy's LD_PRELOAD injection path is only active // on glibc-Linux; on musl, fspy switches to - // seccomp-unotify and strips LD_PRELOAD from + // seccomp-filtered ptrace and strips LD_PRELOAD from // spawned children, which breaks fixtures that // depend on interposer ordering. "linux-gnu" => cfg!(target_os = "linux") && !cfg!(target_env = "musl"),