Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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