Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fbe8a06
feat: add musl target support by disabling fspy-based input inference
claude Mar 19, 2026
7501b6a
feat: add musl target support using seccomp-only tracking
claude Mar 19, 2026
16239f2
feat: add musl target support using seccomp-only tracking
claude Mar 19, 2026
2c4a29e
fix: run musl CI tests in Alpine container for native musl support
claude Mar 19, 2026
cd6b0d3
fix: use sh shell for Alpine apk install step (bash not yet available)
claude Mar 19, 2026
4662e47
fix: override musl linker in Alpine container to use system cc
claude Mar 19, 2026
6895766
fix: override musl rustflags to remove zig linker in Alpine
claude Mar 19, 2026
c63e5b2
fix: remove zig linker config for native musl build in Alpine
claude Mar 19, 2026
a15de2d
fix: gate unused imports behind cfg(not(target_env = "musl"))
claude Mar 19, 2026
0f12daa
fix: gate artifact module and NativeStr import for musl
claude Mar 19, 2026
d204e18
fix: replace ctor-in-macro with linkme + crate-level ctor for musl
claude Mar 19, 2026
ed764d3
fix: add cargo-shear ignore for subprocess_test + seccomp unconfined
claude Mar 19, 2026
7ac7d6b
fix: resolve cargo-shear warnings for linkme/ctor dependencies
claude Mar 19, 2026
2957cf8
fix: increase shared memory size for fspy IPC tests in Docker
claude Mar 19, 2026
2659aad
fix: use --no-fail-fast for musl tests to see full test results
claude Mar 19, 2026
6d5dd4f
fix: downgrade ctor from 0.6 to 0.2 for musl compatibility
claude Mar 19, 2026
b4b298b
revert: restore ctor 0.6 (0.2 didn't fix musl either)
claude Mar 19, 2026
2ce1552
fix: read args from /proc/self/cmdline during .init_array on musl
claude Mar 19, 2026
c60305d
chore: update Cargo.lock for subprocess_test libc dependency
claude Mar 19, 2026
15e328a
fix: preserve empty args when reading /proc/self/cmdline
claude Mar 19, 2026
934c0c7
fix: detect O_DIRECTORY flag in seccomp open handler for READ_DIR
claude Mar 19, 2026
125fdd0
fix: enable fspy seccomp tracing on musl and fix musl test failures
claude Mar 19, 2026
1019416
fix: CI failures from musl config and unused dep detection
claude Mar 19, 2026
e234588
fix: enable Node.js TypeScript strip types on Alpine musl CI
claude Mar 19, 2026
c224568
fix: pass NODE_OPTIONS through to E2E test subprocesses
claude Mar 19, 2026
6165dde
fix: use official Node.js binary on Alpine for TypeScript support
claude Mar 19, 2026
af7f2da
fix: use versioned URL for Node.js musl binary download
claude Mar 19, 2026
8ecc331
refactor: address PR review comments
claude Mar 19, 2026
e52ccb9
refactor: remove unnecessary seccomp=unconfined from Alpine CI container
branchseer Mar 20, 2026
82464f9
refactor: simplify Alpine CI with node image, corepack, and smart lin…
branchseer Mar 20, 2026
6cfce38
fix: use cargo linker config key instead of rustflags for path resolu…
branchseer Mar 20, 2026
1a8f449
chore: add changelog and clean up comments
branchseer Mar 20, 2026
995fa54
refactor: use nix instead of raw libc in subprocess_test, remove CI e…
branchseer Mar 20, 2026
29bfaa0
chore: remove changelog
branchseer Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ rustflags = ["--cfg", "tokio_unstable", "-D", "warnings"]
[unstable]
bindeps = true

# Linker wrappers for cross-compiling bindep targets (fspy_test_bin) via cargo-zigbuild.
# On native Linux the system linker can handle musl targets; these are needed on non-Linux hosts.
# Linker wrappers for musl targets. On Linux hosts these use the system cc directly;
# on non-Linux hosts (macOS, Windows) they cross-compile via cargo-zigbuild.
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "linker=.cargo/zigcc-x86_64-unknown-linux-musl"]

Expand Down
5 changes: 5 additions & 0 deletions .cargo/zigcc-aarch64-unknown-linux-musl
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
#!/bin/sh
# Linker wrapper for aarch64-unknown-linux-musl targets.
# On Linux, use the system cc directly. On other hosts, cross-compile via cargo-zigbuild.
if [ "$(uname -s)" = "Linux" ]; then
exec cc "$@"
fi
exec cargo-zigbuild zig cc -- -fno-sanitize=all -target aarch64-linux-musl "$@"
5 changes: 5 additions & 0 deletions .cargo/zigcc-x86_64-unknown-linux-musl
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
#!/bin/sh
# Linker wrapper for x86_64-unknown-linux-musl targets.
# On Linux, use the system cc directly. On other hosts, cross-compile via cargo-zigbuild.
if [ "$(uname -s)" = "Linux" ]; then
exec cc "$@"
fi
exec cargo-zigbuild zig cc -- -fno-sanitize=all -target x86_64-linux-musl "$@"
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ jobs:
- run: cargo-zigbuild test --target x86_64-unknown-linux-gnu.2.17
if: ${{ matrix.os == 'ubuntu-latest' }}

test-musl:
needs: detect-changes
if: needs.detect-changes.outputs.code-changed == 'true'
name: Test (musl)
runs-on: ubuntu-latest
container:
image: node:22-alpine3.21
options: --shm-size=256m # shm_io tests need bigger shared memory
env:
# Override all rustflags to skip the zig cross-linker from .cargo/config.toml.
# Alpine's cc is already musl-native, so no custom linker is needed.
# Must mirror [build].rustflags from .cargo/config.toml.
RUSTFLAGS: --cfg tokio_unstable -D warnings
steps:
- name: Install Alpine dependencies
shell: sh {0}
run: apk add --no-cache bash curl git musl-dev gcc g++ python3

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
submodules: true

- name: Install rustup
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"

- name: Install Rust toolchain
run: rustup show

- name: Install pnpm and Node tools
run: |
corepack enable
pnpm install

- run: cargo test

fmt:
name: Format and Check Deps
runs-on: ubuntu-latest
Expand Down Expand Up @@ -168,6 +206,7 @@ jobs:
needs:
- clippy
- test
- test-musl
- fmt
steps:
- run: exit 1
Expand Down
4 changes: 3 additions & 1 deletion crates/fspy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ fspy_seccomp_unotify = { workspace = true, features = ["supervisor"] }
nix = { workspace = true, features = ["uio"] }
tokio = { workspace = true, features = ["bytes"] }

[target.'cfg(unix)'.dependencies]
[target.'cfg(all(unix, not(target_env = "musl")))'.dependencies]
fspy_preload_unix = { workspace = true }

[target.'cfg(unix)'.dependencies]
fspy_shared_unix = { workspace = true }
nix = { workspace = true, features = ["fs", "process", "socket", "feature"] }

Expand Down
6 changes: 5 additions & 1 deletion crates/fspy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Run a command and capture all the paths it tries to access.

## macOS/Linux implementation
## macOS/Linux (glibc) implementation

It uses `DYLD_INSERT_LIBRARIES` on macOS and `LD_PRELOAD` on Linux to inject a shared library that intercepts file system calls.
The injection process is almost identical on both platforms other than the environment variable name. The implementation is in `src/unix`.
Expand All @@ -11,6 +11,10 @@ The injection process is almost identical on both platforms other than the envir

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`.

## Linux musl implementation

On musl targets, only `seccomp_unotify`-based tracking is used (no preload library).

## Windows implementation

It uses [Detours](https://github.com/microsoft/Detours) to intercept file system calls. The implementation is in `src/windows`.
Expand Down
2 changes: 2 additions & 0 deletions crates/fspy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#![feature(once_cell_try)]

// Persist the injected DLL/shared library somewhere in the filesystem.
// Not needed on musl (seccomp-only tracking).
#[cfg(not(target_env = "musl"))]
mod artifact;

pub mod error;
Expand Down
42 changes: 28 additions & 14 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use std::{io, path::Path};

#[cfg(target_os = "linux")]
use fspy_seccomp_unotify::supervisor::supervise;
use fspy_shared::ipc::{NativeStr, PathAccess, channel::channel};
#[cfg(not(target_env = "musl"))]
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::{
Expand All @@ -33,28 +35,39 @@ pub struct SpyImpl {
#[cfg(target_os = "macos")]
artifacts: Artifacts,

#[cfg(not(target_env = "musl"))]
preload_path: Box<NativeStr>,
}

#[cfg(not(target_env = "musl"))]
const PRELOAD_CDYLIB_BINARY: &[u8] = include_bytes!(env!("CARGO_CDYLIB_FILE_FSPY_PRELOAD_UNIX"));

impl SpyImpl {
/// Initialize the fs access spy by writing the preload library on disk
pub fn init_in(dir: &Path) -> io::Result<Self> {
use const_format::formatcp;
use xxhash_rust::const_xxh3::xxh3_128;

use crate::artifact::Artifact;

const PRELOAD_CDYLIB: Artifact = Artifact {
name: "fspy_preload",
content: PRELOAD_CDYLIB_BINARY,
hash: formatcp!("{:x}", xxh3_128(PRELOAD_CDYLIB_BINARY)),
/// 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<Self> {
#[cfg(not(target_env = "musl"))]
let preload_path = {
use const_format::formatcp;
use xxhash_rust::const_xxh3::xxh3_128;

use crate::artifact::Artifact;

const PRELOAD_CDYLIB: Artifact = Artifact {
name: "fspy_preload",
content: PRELOAD_CDYLIB_BINARY,
hash: formatcp!("{:x}", xxh3_128(PRELOAD_CDYLIB_BINARY)),
};

let preload_cdylib_path = PRELOAD_CDYLIB.write_to(dir, ".dylib")?;
preload_cdylib_path.as_path().into()
};

let preload_cdylib_path = PRELOAD_CDYLIB.write_to(dir, ".dylib")?;
Ok(Self {
preload_path: preload_cdylib_path.as_path().into(),
#[cfg(not(target_env = "musl"))]
preload_path,
#[cfg(target_os = "macos")]
artifacts: {
let coreutils_path = macos_artifacts::COREUTILS_BINARY.write_to(dir, "")?;
Expand All @@ -80,6 +93,7 @@ impl SpyImpl {
#[cfg(target_os = "macos")]
artifacts: self.artifacts.clone(),

#[cfg(not(target_env = "musl"))]
preload_path: self.preload_path.clone(),

#[cfg(target_os = "linux")]
Expand Down
5 changes: 5 additions & 0 deletions crates/fspy/src/unix/syscall_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ impl_handler!(
#[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,
Expand Down
33 changes: 33 additions & 0 deletions crates/fspy/src/unix/syscall_handler/stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,37 @@ impl SyscallHandler {
) -> 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)
}
}
3 changes: 2 additions & 1 deletion crates/fspy/tests/rust_std.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ async fn readdir() -> anyhow::Result<()> {

let accesses = track_fn!(tmpdir_path.to_str().unwrap().to_owned(), |tmpdir_path: String| {
std::env::set_current_dir(tmpdir_path).unwrap();
let _ = std::fs::read_dir("hello_dir");
// Consume at least one entry so that getdents64 fires on seccomp targets.
let _ = std::fs::read_dir("hello_dir").unwrap().next();
})
.await?;
assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::READ_DIR);
Expand Down
10 changes: 7 additions & 3 deletions crates/fspy_preload_unix/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#![cfg(unix)]
// required for defining inteposed `open`/`openat`(https://man7.org/linux/man-pages/man2/open.2.html)
#![feature(c_variadic)]
// On musl targets, fspy_preload_unix is not needed since we can track accesses via seccomp-only.
// Compile as an empty crate to avoid build failures from missing libc symbols.
#![cfg_attr(not(target_env = "musl"), feature(c_variadic))]

#[cfg(all(unix, not(target_env = "musl")))]
mod client;
#[cfg(all(unix, not(target_env = "musl")))]
mod interceptions;
#[cfg(all(unix, not(target_env = "musl")))]
mod libc;
#[cfg(all(unix, not(target_env = "musl")))]
mod macros;
4 changes: 4 additions & 0 deletions crates/fspy_seccomp_unotify/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ workspace = true
[lib]
test = false
doctest = false

[[test]]
name = "arg_types"
required-features = ["supervisor", "target"]
2 changes: 1 addition & 1 deletion crates/fspy_seccomp_unotify/src/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub fn notif_recv(
notif_buf: &mut Alloced<libc::seccomp_notif>,
) -> nix::Result<()> {
use std::os::fd::AsRawFd;
const SECCOMP_IOCTL_NOTIF_RECV: libc::c_ulong = 3_226_476_800;
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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/fspy_seccomp_unotify/src/supervisor/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ impl AsFd for NotifyListener {
}
}

const SECCOMP_IOCTL_NOTIF_SEND: libc::c_ulong = 3_222_806_785;
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.
Expand Down
2 changes: 1 addition & 1 deletion crates/fspy_seccomp_unotify/tests/arg_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async fn run_in_pre_exec(
async fn fd_and_path() -> Result<(), Box<dyn Error>> {
let syscalls = run_in_pre_exec(|| {
set_current_dir("/")?;
let home_fd = nix::fcntl::open(c"/home", OFlag::O_PATH, Mode::empty())?;
let home_fd = openat(AT_FDCWD, c"/home", OFlag::O_PATH, Mode::empty())?;
let _ = openat(home_fd, c"open_at_home", OFlag::O_RDONLY, Mode::empty());
let _ = openat(AT_FDCWD, c"openat_cwd", OFlag::O_RDONLY, Mode::empty());
Ok(())
Expand Down
3 changes: 3 additions & 0 deletions crates/fspy_shared/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ workspace = true

[lib]
doctest = false

[package.metadata.cargo-shear]
ignored = ["ctor"]
47 changes: 17 additions & 30 deletions crates/fspy_shared/src/ipc/channel/shm_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,6 @@ impl<M: AsRef<[u8]>> ShmReader<M> {
#[cfg(test)]
mod tests {
use std::{
env::current_exe,
process::{Child, Command},
sync::Arc,
thread,
Expand Down Expand Up @@ -669,43 +668,31 @@ mod tests {
#[cfg(not(miri))]
fn real_shm_across_processes() {
use shared_memory::ShmemConf;
use subprocess_test::command_for_fn;

const CHILD_PROCESS_ENV: &str = "FSPY_SHM_IO_TEST_CHILD_PROCESS";
const CHILD_COUNT: usize = 12;
const FRAME_COUNT_EACH_CHILD: usize = 100;

#[ctor::ctor]
fn child_process() {
if std::env::var_os(CHILD_PROCESS_ENV).is_none() {
return;
}
let mut args = std::env::args_os();
args.next().unwrap(); // exe path
let shm_name = args.next().expect("shm name arg").into_string().unwrap();
let child_index = args.next().expect("child name").into_string().unwrap();

let shm = ShmemConf::new().os_id(shm_name).open().unwrap();
// SAFETY: `shm` is a freshly opened shared memory region with a valid pointer and size.
// Concurrent write access is safe because `ShmWriter` uses atomic operations.
let writer = unsafe { ShmWriter::new(shm) };
for i in 0..FRAME_COUNT_EACH_CHILD {
let frame_data = format!("{child_index} {i}");
assert!(writer.try_write_frame(frame_data.as_bytes()));
}
std::process::exit(0);
}

let shm = ShmemConf::new().size(1024 * 1024).create().unwrap();
let shm_name = shm.get_os_id();
let shm_name = shm.get_os_id().to_owned();

let children: Vec<Child> = (0..CHILD_COUNT)
.map(|child_index| {
Command::new(current_exe().unwrap())
.env(CHILD_PROCESS_ENV, "1")
.arg(shm_name)
.arg(child_index.to_string())
.spawn()
.unwrap()
let cmd = command_for_fn!(
(shm_name.clone(), child_index),
|(shm_name, child_index): (String, usize)| {
let shm = ShmemConf::new().os_id(shm_name).open().unwrap();
// SAFETY: `shm` is a freshly opened shared memory region with a valid
// pointer and size. Concurrent write access is safe because `ShmWriter`
// uses atomic operations.
let writer = unsafe { ShmWriter::new(shm) };
for i in 0..FRAME_COUNT_EACH_CHILD {
let frame_data = std::format!("{child_index} {i}");
assert!(writer.try_write_frame(frame_data.as_bytes()));
}
}
);
Command::from(cmd).spawn().unwrap()
})
.collect();

Expand Down
5 changes: 4 additions & 1 deletion crates/fspy_shared_unix/src/payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ use std::os::unix::ffi::OsStringExt;
use base64::{Engine as _, prelude::BASE64_STANDARD_NO_PAD};
use bincode::{Decode, Encode, config::standard};
use bstr::BString;
use fspy_shared::ipc::{NativeStr, channel::ChannelConf};
#[cfg(not(target_env = "musl"))]
use fspy_shared::ipc::NativeStr;
use fspy_shared::ipc::channel::ChannelConf;

#[derive(Debug, Encode, Decode)]
pub struct Payload {
pub ipc_channel_conf: ChannelConf,

#[cfg(not(target_env = "musl"))]
pub preload_path: Box<NativeStr>,

#[cfg(target_os = "macos")]
Expand Down
Loading
Loading