Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ rustflags = ["--cfg", "tokio_unstable"] # also update .github/workflows/ci.yml

[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.
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "linker=.cargo/zigcc-x86_64-unknown-linux-musl"]

[target.aarch64-unknown-linux-musl]
rustflags = ["-C", "linker=.cargo/zigcc-aarch64-unknown-linux-musl"]
2 changes: 2 additions & 0 deletions .cargo/zigcc-aarch64-unknown-linux-musl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
exec cargo-zigbuild zig cc -- -fno-sanitize=all -target aarch64-linux-musl "$@"
2 changes: 2 additions & 0 deletions .cargo/zigcc-x86_64-unknown-linux-musl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
exec cargo-zigbuild zig cc -- -fno-sanitize=all -target x86_64-linux-musl "$@"
12 changes: 7 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
with:
save-cache: ${{ github.ref_name == 'main' }}
cache-key: test
components: clippy

- run: rustup target add ${{ matrix.target }}

Expand All @@ -63,6 +64,8 @@ jobs:
env:
RUSTFLAGS: '-D warnings --cfg tokio_unstable' # also update .cargo/config.toml

- run: cargo clippy --all-targets --all-features -- -D warnings

# Set up node and pnpm for running tests
# For x86_64-apple-darwin, use x64 node for fspy tests that verify Node.js fs accesses
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
Expand All @@ -84,8 +87,8 @@ jobs:
- run: cargo-zigbuild test --target x86_64-unknown-linux-gnu.2.17
if: ${{ matrix.os == 'ubuntu-latest' }}

lint:
name: Lint
fmt:
name: Format and Check Deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand All @@ -96,14 +99,13 @@ jobs:
- uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0
with:
save-cache: ${{ github.ref_name == 'main' }}
cache-key: lint
cache-key: fmt
tools: dprint,cargo-shear
components: clippy rust-docs rustfmt

- run: dprint check
- run: cargo shear
- run: cargo fmt --check
# - run: cargo clippy --all-targets --all-features -- -D warnings
- run: RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items

- uses: crate-ci/typos@85f62a8a84f939ae994ab3763f01a0296d61a7ee # v1.36.2
Expand All @@ -119,7 +121,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- test
- lint
- fmt
steps:
- run: exit 1
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379
Expand Down
5 changes: 5 additions & 0 deletions .rustfmt.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ reorder_impl_items = true
group_imports = "StdExternalCrate"
# Group "use" statements by crate
imports_granularity = "Crate"

# Skip generated files
ignore = [
"crates/fspy_detours_sys/src/generated_bindings.rs",
]
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ just fmt # Format code (cargo fmt, cargo shear, dprint)
just check # Check compilation with all features
just test # Run all tests
just lint # Clippy linting
just lint-linux # Cross-clippy for Linux (requires cargo-zigbuild)
just lint-windows # Cross-clippy for Windows (requires cargo-xwin)
just doc # Documentation generation
```

Expand Down Expand Up @@ -88,6 +90,16 @@ Tasks are defined in `vite-task.json`:
- With `-r/--recursive`: runs task across all packages in dependency order
- With `-t/--transitive`: runs task in current package and its dependencies

## Cross-Platform Linting

After major changes (especially to `fspy*` or platform-specific crates), run cross-platform clippy before pushing:

```bash
just lint # native (host platform)
just lint-linux # Linux via cargo-zigbuild
just lint-windows # Windows via cargo-xwin
```

## Code Constraints

These patterns are enforced by `.clippy.toml`:
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ unimplemented = "warn"
print_stdout = "warn"
print_stderr = "warn"
allow_attributes = "warn"
allow_attributes_without_reason = "warn"
undocumented_unsafe_blocks = "warn"
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
cargo = { level = "warn", priority = -1 }
cargo_common_metadata = "allow"
multiple_crate_versions = "allow"

[workspace.dependencies]
allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc", "std"] }
Expand Down
22 changes: 16 additions & 6 deletions crates/fspy/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
#![allow(
clippy::disallowed_types,
clippy::disallowed_methods,
clippy::disallowed_macros,
reason = "non-vite crate"
)]

use std::{
env::{self, current_dir},
fs,
Expand All @@ -21,7 +28,7 @@ fn download(url: &str) -> anyhow::Result<impl Read + use<>> {
let output = curl.wait_with_output()?;
if !output.status.success() {
bail!("curl exited with status {} trying to download {}", output.status, url);
};
}
Ok(Cursor::new(output.stdout))
}

Expand Down Expand Up @@ -50,19 +57,22 @@ fn download_and_unpack_tar_gz(url: &str, path: &str) -> anyhow::Result<Vec<u8>>
Ok(data)
}

const MACOS_BINARY_DOWNLOADS: &[(&str, &[(&str, &str, u128)])] = &[
/// (url, `path_in_targz`, `expected_hash`)
type BinaryDownload = (&'static str, &'static str, u128);

const MACOS_BINARY_DOWNLOADS: &[(&str, &[BinaryDownload])] = &[
(
"aarch64",
&[
(
"https://github.com/branchseer/oils-for-unix-build/releases/download/oils-for-unix-0.37.0/oils-for-unix-0.37.0-darwin-arm64.tar.gz",
"oils-for-unix",
282073174065923237490435663309538399576,
282_073_174_065_923_237_490_435_663_309_538_399_576,
),
(
"https://github.com/uutils/coreutils/releases/download/0.4.0/coreutils-0.4.0-aarch64-apple-darwin.tar.gz",
"coreutils-0.4.0-aarch64-apple-darwin/coreutils",
35998406686137668997937014088186935383,
35_998_406_686_137_668_997_937_014_088_186_935_383,
),
],
),
Expand All @@ -72,12 +82,12 @@ const MACOS_BINARY_DOWNLOADS: &[(&str, &[(&str, &str, u128)])] = &[
(
"https://github.com/branchseer/oils-for-unix-build/releases/download/oils-for-unix-0.37.0/oils-for-unix-0.37.0-darwin-x86_64.tar.gz",
"oils-for-unix",
142673558272427867831039361796426010330,
142_673_558_272_427_867_831_039_361_796_426_010_330,
),
(
"https://github.com/uutils/coreutils/releases/download/0.4.0/coreutils-0.4.0-x86_64-apple-darwin.tar.gz",
"coreutils-0.4.0-x86_64-apple-darwin/coreutils",
120898281113671104995723556995187526689,
120_898_281_113_671_104_995_723_556_995_187_526_689,
),
],
),
Expand Down
18 changes: 16 additions & 2 deletions crates/fspy/examples/cli.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
#![allow(
clippy::disallowed_types,
clippy::disallowed_methods,
clippy::disallowed_macros,
reason = "non-vite crate"
)]

use std::{env::args_os, ffi::OsStr, path::PathBuf, pin::Pin};

use tokio::{
Expand Down Expand Up @@ -29,15 +36,22 @@ async fn main() -> anyhow::Result<()> {

for acc in termination.path_accesses.iter() {
path_count += 1;
let mode_str = format!("{:?}", acc.mode);
csv_writer
.write_record(&[
acc.path.to_cow_os_str().to_string_lossy().as_ref().as_bytes(),
format!("{:?}", acc.mode).as_bytes(),
mode_str.as_bytes(),
])
.await?;
}
csv_writer.flush().await?;

eprintln!("\nfspy: {path_count} paths accessed. status: {}", termination.status);
#[expect(
clippy::print_stderr,
reason = "CLI example: stderr output is intentional for user feedback"
)]
{
eprintln!("\nfspy: {path_count} paths accessed. status: {}", termination.status);
}
Ok(())
}
16 changes: 10 additions & 6 deletions crates/fspy/src/arena.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
#![expect(
clippy::future_not_send,
reason = "ouroboros generates async builder methods that cannot satisfy Send bounds"
)]

use allocator_api2::vec::Vec;
use bumpalo::Bump;

Expand Down Expand Up @@ -29,10 +34,9 @@ impl PathAccessArena {
}
}

#[expect(
clippy::non_send_fields_in_send_ty,
reason = "bump and accesses are safe to be sent across threads together"
)]
/// SAFETY: bump and accesses are safe to send together
unsafe impl Send for PathAccessArena {}

// impl PathAccessArena {
// pub fn as_slice(&self) -> &[PathAccess<'_>] {
// self.borrow_accesses().as_slice()
// }
// }
25 changes: 19 additions & 6 deletions crates/fspy/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,25 @@ impl Command {
self
}

/// Spawn the command with file system access tracking.
///
/// # Errors
///
/// Returns [`SpawnError`] if program resolution fails or the process cannot be spawned.
pub async fn spawn(mut self) -> Result<TrackedChild, SpawnError> {
self.resolve_program()?;
SPY_IMPL.spawn(self).await
}

/// Resolve program name to full path using `PATH` and cwd.
///
/// # Errors
///
/// Returns [`SpawnError::Which`] if the program cannot be found in `PATH`.
///
/// # Panics
///
/// Panics if no `cwd` is set and `std::env::current_dir()` fails.
pub fn resolve_program(&mut self) -> Result<(), SpawnError> {
let mut path_env: Option<&OsStr> = None;
for (env_name, env_value) in &self.envs {
Expand All @@ -180,13 +193,12 @@ impl Command {
}
}

let cwd = if let Some(cwd) = &self.cwd {
cwd.clone()
} else {
std::env::current_dir().expect("failed to get current dir")
};
let cwd = self
.cwd
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("failed to get current dir"));
self.program = which::which_in(self.program.as_os_str(), path_env, &cwd)
.map_err(|err| SpawnError::WhichError {
.map_err(|err| SpawnError::Which {
program: self.program.clone(),
path: path_env.map(OsStr::to_owned),
cwd,
Expand All @@ -211,6 +223,7 @@ impl Command {
}

/// Convert to a `tokio::process::Command` without tracking.
#[must_use]
pub fn into_tokio_command(self) -> TokioCommand {
let mut tokio_cmd = TokioCommand::new(self.program);
if let Some(cwd) = &self.cwd {
Expand Down
10 changes: 5 additions & 5 deletions crates/fspy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ pub enum SpawnError {
#[error(
"could not resolve the full path of program '{program:?}' with PATH={path:?} under cwd({cwd:?})"
)]
WhichError {
Which {
program: OsString,
path: Option<OsString>,
cwd: PathBuf,
Expand All @@ -14,16 +14,16 @@ pub enum SpawnError {
},

#[error("failed to initialize seccomp_unotify supervisor: {0}")]
SupervisorError(std::io::Error),
Supervisor(std::io::Error),

#[error("failed to create IPC channel: {0}")]
ChannelCreationError(std::io::Error),
ChannelCreation(std::io::Error),

/// On unix systems, the injection happens before the spawn actually occurs on.
/// On Windows, the injection happens after the spawn but before resuming the process.
#[error("failed to prepare the command for injection: {0}")]
InjectionError(std::io::Error),
Injection(std::io::Error),

#[error("underlying os error: {0}")]
OsSpawnError(std::io::Error),
OsSpawn(std::io::Error),
}
7 changes: 6 additions & 1 deletion crates/fspy/src/ipc.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
#![expect(
clippy::future_not_send,
reason = "ouroboros generates async builder methods that cannot satisfy Send bounds"
)]

use std::io;

use bincode::borrow_decode_from_slice;
Expand All @@ -24,7 +29,7 @@ pub struct OwnedReceiverLockGuard {

impl OwnedReceiverLockGuard {
pub fn lock(receiver: Receiver) -> io::Result<Self> {
OwnedReceiverLockGuard::try_new(receiver, |receiver| receiver.lock())
Self::try_new(receiver, fspy_shared::ipc::channel::Receiver::lock)
}

pub async fn lock_async(receiver: Receiver) -> io::Result<Self> {
Expand Down
6 changes: 6 additions & 0 deletions crates/fspy/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
#![cfg_attr(target_os = "windows", feature(windows_process_extensions_main_thread_handle))]
#![feature(once_cell_try)]
#![allow(
clippy::disallowed_types,
clippy::disallowed_methods,
clippy::disallowed_macros,
reason = "non-vite crate"
)]

// Persist the injected DLL/shared library somewhere in the filesystem.
mod artifact;
Expand Down
Loading
Loading