Skip to content

Commit 61df44b

Browse files
wan9chicodex
andcommitted
feat(fspy): prototype ptrace SIGSYS backend
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 95e5551 commit 61df44b

20 files changed

Lines changed: 711 additions & 386 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fspy/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ tokio-util = { workspace = true }
2323
which = { workspace = true, features = ["tracing"] }
2424

2525
[target.'cfg(target_os = "linux")'.dependencies]
26-
fspy_seccomp_unotify = { workspace = true, features = ["supervisor"] }
2726
nix = { workspace = true, features = ["uio"] }
2827
tokio = { workspace = true, features = ["bytes"] }
2928

@@ -61,6 +60,7 @@ fspy_test_bin = { path = "../fspy_test_bin", artifact = "bin", target = "x86_64-
6160
# builds are cheap.
6261
[build-dependencies]
6362
anyhow = { workspace = true }
63+
cc = { workspace = true }
6464
materialized_artifact_build = { workspace = true }
6565
flate2 = { workspace = true }
6666
fspy_preload_unix = { workspace = true }

crates/fspy/build.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ fn fetch_macos_binaries(out_dir: &Path) -> anyhow::Result<()> {
146146
fn register_preload_cdylib() -> anyhow::Result<()> {
147147
let env_name = match env::var("CARGO_CFG_TARGET_OS").unwrap().as_str() {
148148
"windows" => "CARGO_CDYLIB_FILE_FSPY_PRELOAD_WINDOWS",
149+
"linux" => return Ok(()),
149150
_ if env::var("CARGO_CFG_TARGET_ENV").unwrap() == "musl" => return Ok(()),
150151
_ => "CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX",
151152
};
@@ -157,8 +158,24 @@ fn register_preload_cdylib() -> anyhow::Result<()> {
157158
Ok(())
158159
}
159160

161+
fn build_linux_sigsys_injector() {
162+
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux")
163+
|| env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64")
164+
{
165+
return;
166+
}
167+
168+
println!("cargo:rerun-if-changed=src/unix/sigsys_x86_64.c");
169+
cc::Build::new()
170+
.file("src/unix/sigsys_x86_64.c")
171+
.flag_if_supported("-std=c11")
172+
.warnings(true)
173+
.compile("fspy_sigsys_x86_64");
174+
}
175+
160176
fn main() -> anyhow::Result<()> {
161177
println!("cargo:rerun-if-changed=build.rs");
178+
build_linux_sigsys_injector();
162179
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
163180
fetch_macos_binaries(&out_dir).context("Failed to fetch macOS binaries")?;
164181
register_preload_cdylib().context("Failed to register preload cdylib")?;

crates/fspy/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
pub mod error;
44

5-
#[cfg(not(target_env = "musl"))]
65
mod ipc;
76

87
#[cfg(unix)]

crates/fspy/src/unix/mod.rs

Lines changed: 67 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
#[cfg(target_os = "linux")]
2-
mod syscall_handler;
2+
mod sigsys;
33

44
#[cfg(target_os = "macos")]
55
mod macos_artifacts;
66

7+
#[cfg(target_os = "linux")]
8+
use std::os::fd::AsRawFd as _;
79
use std::{io, path::Path};
810

9-
#[cfg(target_os = "linux")]
10-
use fspy_seccomp_unotify::supervisor::supervise;
11-
use fspy_shared::ipc::PathAccess;
12-
#[cfg(not(target_env = "musl"))]
13-
use fspy_shared::ipc::{NativeStr, channel::channel};
11+
#[cfg(target_os = "macos")]
12+
use fspy_shared::ipc::NativeStr;
13+
use fspy_shared::ipc::{PathAccess, channel::channel};
1414
#[cfg(target_os = "macos")]
1515
use fspy_shared_unix::payload::Artifacts;
1616
use fspy_shared_unix::{
@@ -19,31 +19,39 @@ use fspy_shared_unix::{
1919
spawn::handle_exec,
2020
};
2121
use futures_util::FutureExt;
22-
#[cfg(target_os = "linux")]
23-
use syscall_handler::SyscallHandler;
2422
use tokio::task::spawn_blocking;
2523
use tokio_util::sync::CancellationToken;
2624

27-
#[cfg(not(target_env = "musl"))]
28-
use crate::ipc::{OwnedReceiverLockGuard, SHM_CAPACITY};
29-
use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};
25+
use crate::{
26+
ChildTermination, Command, TrackedChild,
27+
arena::PathAccessArena,
28+
error::SpawnError,
29+
ipc::{OwnedReceiverLockGuard, SHM_CAPACITY},
30+
};
3031

3132
#[derive(Debug)]
3233
pub struct SpyImpl {
3334
#[cfg(target_os = "macos")]
3435
artifacts: Artifacts,
3536

36-
#[cfg(not(target_env = "musl"))]
37+
#[cfg(target_os = "macos")]
3738
preload_path: Box<NativeStr>,
3839
}
3940

4041
impl SpyImpl {
41-
/// Initialize the fs access spy by writing the preload library on disk.
42-
///
43-
/// On musl targets, we don't build a preload library —
44-
/// only seccomp-based tracking is used.
45-
pub fn init_in(#[cfg_attr(target_env = "musl", allow(unused))] dir: &Path) -> io::Result<Self> {
46-
#[cfg(not(target_env = "musl"))]
42+
/// Initializes platform artifacts. Linux injects its handler at exec and
43+
/// does not materialize a preload library.
44+
#[cfg(target_os = "linux")]
45+
#[expect(
46+
clippy::unnecessary_wraps,
47+
reason = "keeps initialization uniform with the fallible macOS backend"
48+
)]
49+
pub const fn init_in(_dir: &Path) -> io::Result<Self> {
50+
Ok(Self {})
51+
}
52+
53+
#[cfg(target_os = "macos")]
54+
pub fn init_in(dir: &Path) -> io::Result<Self> {
4755
let preload_path = {
4856
use materialized_artifact::{Artifact, artifact};
4957

@@ -54,7 +62,7 @@ impl SpyImpl {
5462
};
5563

5664
Ok(Self {
57-
#[cfg(not(target_env = "musl"))]
65+
#[cfg(target_os = "macos")]
5866
preload_path,
5967
#[cfg(target_os = "macos")]
6068
artifacts: {
@@ -74,25 +82,21 @@ impl SpyImpl {
7482
mut command: Command,
7583
cancellation_token: CancellationToken,
7684
) -> Result<TrackedChild, SpawnError> {
77-
#[cfg(target_os = "linux")]
78-
let supervisor = supervise::<SyscallHandler>().map_err(SpawnError::Supervisor)?;
79-
80-
#[cfg(not(target_env = "musl"))]
85+
#[cfg(target_os = "macos")]
8186
let (ipc_channel_conf, ipc_receiver) =
8287
channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?;
88+
#[cfg(target_os = "linux")]
89+
let (_, ipc_receiver) = channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?;
8390

8491
let payload = Payload {
85-
#[cfg(not(target_env = "musl"))]
92+
#[cfg(target_os = "macos")]
8693
ipc_channel_conf,
8794

8895
#[cfg(target_os = "macos")]
8996
artifacts: self.artifacts.clone(),
9097

91-
#[cfg(not(target_env = "musl"))]
98+
#[cfg(target_os = "macos")]
9299
preload_path: self.preload_path.clone(),
93-
94-
#[cfg(target_os = "linux")]
95-
seccomp_payload: supervisor.payload().clone(),
96100
};
97101

98102
let encoded_payload = encode_payload(payload);
@@ -108,28 +112,51 @@ impl SpyImpl {
108112
},
109113
)
110114
.map_err(|err| SpawnError::Injection(err.into()))?;
115+
#[cfg(target_os = "linux")]
116+
debug_assert!(pre_exec.is_none());
111117
command.set_exec(exec);
112118
command.env("FSPY", "1");
113119

114120
let mut tokio_command = command.into_tokio_command();
115121

116-
// SAFETY: the pre_exec closure only calls pre_exec.run() which is safe to call in a fork context
122+
#[cfg(target_os = "linux")]
123+
let shm_fd = ipc_receiver.shm_fd().as_raw_fd();
124+
#[cfg(target_os = "linux")]
125+
let shm_len = ipc_receiver.shm_len();
126+
127+
// SAFETY: both platform hooks are restricted to async-signal-safe raw
128+
// operations in the post-fork child.
117129
unsafe {
118130
tokio_command.pre_exec(move || {
131+
#[cfg(target_os = "linux")]
132+
sigsys::prepare(shm_fd)?;
133+
#[cfg(target_os = "macos")]
119134
if let Some(pre_exec) = pre_exec.as_ref() {
120135
pre_exec.run()?;
121136
}
122137
Ok(())
123138
});
124139
}
125140

126-
// tokio_command.spawn blocks while executing the `pre_exec` closure.
127-
// Run it inside spawn_blocking to avoid blocking the tokio runtime, especially the supervisor loop,
128-
// which needs to accept incoming connections while `pre_exec` is connecting to it.
129-
let mut child = spawn_blocking(move || tokio_command.spawn())
130-
.await
131-
.map_err(|err| SpawnError::OsSpawn(err.into()))?
132-
.map_err(SpawnError::OsSpawn)?;
141+
// Spawn and the post-exec ptrace handshake are blocking operations.
142+
let mut child = spawn_blocking(move || {
143+
let child = tokio_command.spawn().map_err(SpawnError::OsSpawn)?;
144+
#[cfg(target_os = "linux")]
145+
let child = {
146+
let mut child = child;
147+
let pid = child.id().ok_or_else(|| {
148+
SpawnError::Injection(io::Error::other("spawned child has no process id"))
149+
})?;
150+
if let Err(error) = sigsys::inject(pid, shm_fd, shm_len) {
151+
let _ = child.start_kill();
152+
return Err(SpawnError::Injection(error));
153+
}
154+
child
155+
};
156+
Ok(child)
157+
})
158+
.await
159+
.map_err(|err| SpawnError::OsSpawn(err.into()))??;
133160

134161
Ok(TrackedChild {
135162
stdin: child.stdin.take(),
@@ -146,28 +173,13 @@ impl SpyImpl {
146173
}
147174
};
148175

149-
let arenas = std::iter::once(exec_resolve_accesses);
150-
// Stop the supervisor and collect path accesses from it.
151-
#[cfg(target_os = "linux")]
152-
let arenas = arenas.chain(
153-
supervisor
154-
.stop()
155-
.await?
156-
.into_iter()
157-
.map(syscall_handler::SyscallHandler::into_arena),
158-
);
159-
let arenas = arenas.collect::<Vec<_>>();
176+
let arenas = vec![exec_resolve_accesses];
160177

161178
// Lock the ipc channel after the child has exited.
162179
// We are not interested in path accesses from descendants after the main child has exited.
163-
#[cfg(not(target_env = "musl"))]
164180
let ipc_receiver_lock_guard =
165181
OwnedReceiverLockGuard::lock_async(ipc_receiver).await?;
166-
let path_accesses = PathAccessIterable {
167-
arenas,
168-
#[cfg(not(target_env = "musl"))]
169-
ipc_receiver_lock_guard,
170-
};
182+
let path_accesses = PathAccessIterable { arenas, ipc_receiver_lock_guard };
171183

172184
io::Result::Ok(ChildTermination { status, path_accesses })
173185
})
@@ -179,7 +191,6 @@ impl SpyImpl {
179191

180192
pub struct PathAccessIterable {
181193
arenas: Vec<PathAccessArena>,
182-
#[cfg(not(target_env = "musl"))]
183194
ipc_receiver_lock_guard: OwnedReceiverLockGuard,
184195
}
185196

@@ -188,14 +199,7 @@ impl PathAccessIterable {
188199
let accesses_in_arena =
189200
self.arenas.iter().flat_map(|arena| arena.borrow_accesses().iter()).copied();
190201

191-
#[cfg(not(target_env = "musl"))]
192-
{
193-
let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses();
194-
accesses_in_shm.chain(accesses_in_arena)
195-
}
196-
#[cfg(target_env = "musl")]
197-
{
198-
accesses_in_arena
199-
}
202+
let accesses_in_shm = self.ipc_receiver_lock_guard.iter_path_accesses();
203+
accesses_in_shm.chain(accesses_in_arena)
200204
}
201205
}

crates/fspy/src/unix/sigsys.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use std::{io, os::fd::RawFd};
2+
3+
#[cfg(target_arch = "x86_64")]
4+
unsafe extern "C" {
5+
fn fspy_sigsys_prepare(shm_fd: libc::c_int) -> libc::c_int;
6+
fn fspy_sigsys_inject(
7+
pid: libc::pid_t,
8+
shm_fd: libc::c_int,
9+
shm_len: libc::size_t,
10+
) -> libc::c_int;
11+
}
12+
13+
/// Marks the child traceable and installs the selective TRAP filter.
14+
///
15+
/// # Safety
16+
///
17+
/// This must only run in the post-fork child immediately before exec.
18+
pub unsafe fn prepare(shm_fd: RawFd) -> io::Result<()> {
19+
#[cfg(target_arch = "x86_64")]
20+
{
21+
// SAFETY: the caller guarantees the pre-exec child context and the fd
22+
// is the live channel memfd inherited from the parent.
23+
if unsafe { fspy_sigsys_prepare(shm_fd) } == 0 {
24+
Ok(())
25+
} else {
26+
Err(io::Error::last_os_error())
27+
}
28+
}
29+
30+
#[cfg(not(target_arch = "x86_64"))]
31+
{
32+
let _ = shm_fd;
33+
Err(io::Error::new(
34+
io::ErrorKind::Unsupported,
35+
"the experimental SIGSYS injector supports Linux x86-64 only",
36+
))
37+
}
38+
}
39+
40+
/// Waits for the child's post-exec ptrace stop, maps the existing IPC shared
41+
/// memory, installs the in-process handler, and detaches.
42+
pub fn inject(pid: u32, shm_fd: RawFd, shm_len: usize) -> io::Result<()> {
43+
#[cfg(target_arch = "x86_64")]
44+
{
45+
let pid = libc::pid_t::try_from(pid)
46+
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "child pid exceeds pid_t"))?;
47+
// SAFETY: `pid` is the freshly spawned TRACEME child and the fd/length
48+
// identify the channel mapping inherited across its exec.
49+
if unsafe { fspy_sigsys_inject(pid, shm_fd, shm_len) } == 0 {
50+
Ok(())
51+
} else {
52+
Err(io::Error::last_os_error())
53+
}
54+
}
55+
56+
#[cfg(not(target_arch = "x86_64"))]
57+
{
58+
let _ = (pid, shm_fd, shm_len);
59+
Err(io::Error::new(
60+
io::ErrorKind::Unsupported,
61+
"the experimental SIGSYS injector supports Linux x86-64 only",
62+
))
63+
}
64+
}

0 commit comments

Comments
 (0)