diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index 8b1331ce4..1c69aad21 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -21,7 +21,7 @@ async fn main() -> anyhow::Result<()> { let child = command.spawn(tokio_util::sync::CancellationToken::new()).await?; let termination = child.wait_handle.await?; - let mut path_count = 0usize; + let mut access_count = 0usize; let out_file: Pin> = if out_path == "-" { Box::pin(stdout()) } else { Box::pin(File::create(out_path).await?) }; @@ -32,10 +32,9 @@ async fn main() -> anyhow::Result<()> { .expect("the tracking region holds every record this run makes") .iter() { - path_count += 1; - let path_str = format!("{:?}", acc.path); - let mode_str = format!("{:?}", acc.mode); - csv_writer.write_record(&[path_str.as_bytes(), mode_str.as_bytes()]).await?; + access_count += 1; + let access_str = format!("{acc:?}"); + csv_writer.write_record(&[access_str.as_bytes()]).await?; } csv_writer.flush().await?; @@ -44,7 +43,7 @@ async fn main() -> anyhow::Result<()> { reason = "CLI example: stderr output is intentional for user feedback" )] { - eprintln!("\nfspy: {path_count} paths accessed. status: {}", termination.status); + eprintln!("\nfspy: {access_count} accesses. status: {}", termination.status); } Ok(()) } diff --git a/crates/fspy/src/arena.rs b/crates/fspy/src/arena.rs index e0d466ba7..5ff4bd04a 100644 --- a/crates/fspy/src/arena.rs +++ b/crates/fspy/src/arena.rs @@ -21,9 +21,7 @@ impl Default for PathAccessArena { impl PathAccessArena { pub fn add(&mut self, access: PathAccess<'_>) { self.with_mut(|fields| { - let path = access.path.clone_in(fields.bump); - let path_access = PathAccess { mode: access.mode, path }; - fields.accesses.push(path_access); + fields.accesses.push(access.clone_in(fields.bump)); }); } } diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 75720a772..0bab440a4 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -18,7 +18,7 @@ use std::{env::temp_dir, fs::create_dir, io, process::ExitStatus, sync::LazyLock pub use command::Command; pub use error::TrackingIncomplete; -pub use fspy_shared::ipc::{AccessMode, PathAccess}; +pub use fspy_shared::ipc::{IpcPath, PathAccess}; use futures_util::future::BoxFuture; pub use os_impl::PathAccessIterable; use os_impl::SpyImpl; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 4a83971c7..e5a8f70c5 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -128,8 +128,8 @@ impl SpyImpl { &mut exec, ExecResolveConfig::search_path_enabled(None), &encoded_payload, - |mode, path| { - exec_resolve_accesses.add(PathAccess { mode, path: path.into() }); + |path| { + exec_resolve_accesses.add(PathAccess::Read(path.into())); }, ) .map_err(|err| SpawnError::Injection(err.into()))?; diff --git a/crates/fspy/src/unix/syscall_handler/mod.rs b/crates/fspy/src/unix/syscall_handler/mod.rs index 3a4c7ce55..f3c4661e0 100644 --- a/crates/fspy/src/unix/syscall_handler/mod.rs +++ b/crates/fspy/src/unix/syscall_handler/mod.rs @@ -16,7 +16,7 @@ use fspy_seccomp_unotify::{ impl_handler, supervisor::handler::arg::{CStrPtr, Caller, Fd}, }; -use fspy_shared::ipc::{AccessMode, PathAccess, channel::Sender}; +use fspy_shared::ipc::{PathAccess, channel::Sender}; const PATH_MAX: usize = libc::PATH_MAX as usize; @@ -54,13 +54,11 @@ impl SyscallHandler { } path = Cow::Owned(resolved_path); } - self.ipc_sender.send(&PathAccess { - mode: match flags & libc::O_ACCMODE { - libc::O_RDWR => AccessMode::ReadWrite, - libc::O_WRONLY => AccessMode::Write, - _ => AccessMode::Read, - }, - path: path.as_os_str().into(), + let path = path.as_os_str().into(); + self.ipc_sender.send(&match flags & libc::O_ACCMODE { + libc::O_RDWR => PathAccess::ReadWrite(path), + libc::O_WRONLY => PathAccess::Write(path), + _ => PathAccess::Read(path), }); Ok(()) } @@ -71,10 +69,7 @@ impl SyscallHandler { )] fn handle_open_dir(&mut self, caller: Caller, fd: Fd) -> io::Result<()> { let path = fd.get_path(caller)?; - self.ipc_sender.send(&PathAccess { - mode: AccessMode::ReadDir, - path: OsStr::from_bytes(path.as_bytes()).into(), - }); + self.ipc_sender.send(&PathAccess::ReadDir(OsStr::from_bytes(path.as_bytes()).into())); Ok(()) } } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index 9d8675eed..3940b4dc2 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -7,7 +7,7 @@ use std::{ path::PathBuf, }; -use fspy::{AccessMode, PathAccessIterable}; +use fspy::{PathAccess, PathAccessIterable}; use ntest::test_case; use test_utils::assert_contains; @@ -64,7 +64,7 @@ fn track_script( #[cfg_attr(not(target_env = "musl"), ignore = "requires node")] fn read_sync(runtime: &str) -> anyhow::Result<()> { let accesses = track_script(runtime, "try { fs.readFileSync('hello') } catch {}", &[])?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -76,7 +76,7 @@ fn read_sync(runtime: &str) -> anyhow::Result<()> { #[cfg_attr(not(target_env = "musl"), ignore = "requires node")] fn exist_sync(runtime: &str) -> anyhow::Result<()> { let accesses = track_script(runtime, "try { fs.existsSync('hello') } catch {}", &[])?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -88,7 +88,7 @@ fn exist_sync(runtime: &str) -> anyhow::Result<()> { #[cfg_attr(not(target_env = "musl"), ignore = "requires node")] fn stat_sync(runtime: &str) -> anyhow::Result<()> { let accesses = track_script(runtime, "try { fs.statSync('hello') } catch {}", &[])?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -104,7 +104,7 @@ fn create_read_stream(runtime: &str) -> anyhow::Result<()> { "try { fs.createReadStream('hello').on('error', () => {}) } catch {}", &[], )?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -122,7 +122,7 @@ fn create_write_stream(runtime: &str) -> anyhow::Result<()> { "try { fs.createWriteStream(process.argv.at(-1)).on('error', () => {}) } catch {}", &[file_path.as_os_str()], )?; - assert_contains(&accesses, file_path.as_path(), AccessMode::Write); + assert_contains(&accesses, file_path.as_path(), PathAccess::Write); Ok(()) } @@ -140,7 +140,7 @@ fn write_sync(runtime: &str) -> anyhow::Result<()> { "try { fs.writeFileSync(process.argv.at(-1), '') } catch {}", &[file_path.as_os_str()], )?; - assert_contains(&accesses, &file_path, AccessMode::Write); + assert_contains(&accesses, &file_path, PathAccess::Write); Ok(()) } @@ -152,7 +152,7 @@ fn write_sync(runtime: &str) -> anyhow::Result<()> { #[cfg_attr(not(target_env = "musl"), ignore = "requires node")] fn read_dir_sync(runtime: &str) -> anyhow::Result<()> { let accesses = track_script(runtime, "try { fs.readdirSync('.') } catch {}", &[])?; - assert_contains(&accesses, ¤t_dir().unwrap(), AccessMode::ReadDir); + assert_contains(&accesses, ¤t_dir().unwrap(), PathAccess::ReadDir); Ok(()) } @@ -173,6 +173,6 @@ fn subprocess(runtime: &str) -> anyhow::Result<()> { &format!("try {{ child_process.spawnSync({cmd}, {{ stdio: 'ignore' }}) }} catch {{}}"), &[], )?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } diff --git a/crates/fspy/tests/node_sea.rs b/crates/fspy/tests/node_sea.rs index a3332fa81..85e0da591 100644 --- a/crates/fspy/tests/node_sea.rs +++ b/crates/fspy/tests/node_sea.rs @@ -7,7 +7,7 @@ use std::{ process::Command, }; -use fspy::AccessMode; +use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; @@ -87,6 +87,6 @@ async fn node_sea() -> anyhow::Result<()> { command.spawn(tokio_util::sync::CancellationToken::new()).await?.wait_handle.await?; assert!(termination.status.success(), "{:?}", termination.status); let accesses = termination.path_accesses.expect("the test region holds every record"); - assert_contains(&accesses, &input, AccessMode::Read); + assert_contains(&accesses, &input, PathAccess::Read); Ok(()) } diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index e3d797d5b..822bba543 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -2,7 +2,7 @@ mod test_utils; use std::{env::vars_os, ffi::OsString}; -use fspy::{AccessMode, PathAccessIterable}; +use fspy::{PathAccess, PathAccessIterable}; use test_log::test; /// Get the packages/tools/.bin directory path @@ -63,7 +63,7 @@ async fn oxlint_reads_js_file() -> anyhow::Result<()> { let accesses = track_oxlint(&tmpdir_path, &[]).await?; // Check that oxlint read the JS file - test_utils::assert_contains(&accesses, &js_file, AccessMode::Read); + test_utils::assert_contains(&accesses, &js_file, PathAccess::Read); Ok(()) } @@ -80,7 +80,7 @@ async fn oxlint_reads_directory() -> anyhow::Result<()> { // Check that oxlint read the directory to find JS files // This is the key check - if READ_DIR is not tracked, cache won't detect new files - test_utils::assert_contains(&accesses, &tmpdir_path, AccessMode::ReadDir); + test_utils::assert_contains(&accesses, &tmpdir_path, PathAccess::ReadDir); Ok(()) } @@ -104,9 +104,11 @@ declare const _foo: Foo; // Run oxlint without --type-aware first let accesses = track_oxlint(&tmpdir_path, &[""]).await?; let access_to_types_ts = accesses.iter().find(|access| { - access - .path - .strip_path_prefix(&tmpdir_path, |result| result.is_ok_and(|p| p.ends_with("types.ts"))) + access.paths().any(|path| { + path.strip_path_prefix(&tmpdir_path, |result| { + result.is_ok_and(|p| p.ends_with("types.ts")) + }) + }) }); assert_eq!(access_to_types_ts, None, "oxlint should not read types.ts without --type-aware"); @@ -114,7 +116,7 @@ declare const _foo: Foo; let accesses = track_oxlint(&tmpdir_path, &["--type-aware"]).await?; // Check that oxlint read types.ts - test_utils::assert_contains(&accesses, &tmpdir_path.join("types.ts"), AccessMode::Read); + test_utils::assert_contains(&accesses, &tmpdir_path.join("types.ts"), PathAccess::Read); Ok(()) } diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index ad11ee673..2f84fbb2e 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -6,7 +6,7 @@ use std::{ process::Stdio, }; -use fspy::AccessMode; +use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; @@ -16,7 +16,7 @@ async fn open_read() -> anyhow::Result<()> { let _ = File::open("hello"); }) .await?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -30,7 +30,7 @@ async fn open_write() -> anyhow::Result<()> { let _ = OpenOptions::new().write(true).open(tmp_path_str); }) .await?; - assert_contains(&accesses, tmp_path.as_path(), AccessMode::Write); + assert_contains(&accesses, tmp_path.as_path(), PathAccess::Write); Ok(()) } @@ -46,7 +46,7 @@ async fn metadata() -> anyhow::Result<()> { let _ = fs::metadata(tmp_path_str); }) .await?; - assert_contains(&accesses, tmp_path.as_path(), AccessMode::Read); + assert_contains(&accesses, tmp_path.as_path(), PathAccess::Read); Ok(()) } @@ -72,7 +72,7 @@ async fn readdir() -> anyhow::Result<()> { let _ = std::fs::read_dir("hello_dir").unwrap().next(); }) .await?; - assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::ReadDir); + assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), PathAccess::ReadDir); Ok(()) } @@ -99,7 +99,7 @@ async fn read_in_subprocess() -> anyhow::Result<()> { .unwrap(); }) .await?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -113,7 +113,7 @@ async fn read_program() -> anyhow::Result<()> { assert_contains( &accesses, current_dir().unwrap().join("not_exist.exe").as_path(), - AccessMode::Read, + PathAccess::Read, ); Ok(()) diff --git a/crates/fspy/tests/rust_tokio.rs b/crates/fspy/tests/rust_tokio.rs index 0853f9624..e1e6dd28d 100644 --- a/crates/fspy/tests/rust_tokio.rs +++ b/crates/fspy/tests/rust_tokio.rs @@ -2,7 +2,7 @@ mod test_utils; use std::{env::current_dir, process::Stdio}; -use fspy::AccessMode; +use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; use tokio::fs::OpenOptions; @@ -17,7 +17,7 @@ async fn open_read() -> anyhow::Result<()> { ); }) .await?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } @@ -35,7 +35,7 @@ async fn open_write() -> anyhow::Result<()> { ); }) .await?; - assert_contains(&accesses, tmp_path.as_path(), AccessMode::Write); + assert_contains(&accesses, tmp_path.as_path(), PathAccess::Write); Ok(()) } @@ -56,7 +56,7 @@ async fn readdir() -> anyhow::Result<()> { ); }) .await?; - assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), AccessMode::ReadDir); + assert_contains(&accesses, tmpdir_path.join("hello_dir").as_path(), PathAccess::ReadDir); Ok(()) } @@ -88,7 +88,7 @@ async fn subprocess() -> anyhow::Result<()> { ); }) .await?; - assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), AccessMode::Read); + assert_contains(&accesses, current_dir().unwrap().join("hello").as_path(), PathAccess::Read); Ok(()) } diff --git a/crates/fspy/tests/shebang.rs b/crates/fspy/tests/shebang.rs index 1253ec83a..aacf28cf7 100644 --- a/crates/fspy/tests/shebang.rs +++ b/crates/fspy/tests/shebang.rs @@ -7,7 +7,7 @@ use std::{ process::{Command, Stdio}, }; -use fspy::AccessMode; +use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; use tokio::fs; @@ -36,8 +36,8 @@ async fn spawn_sh_shebang() -> anyhow::Result<()> { }) .await?; - assert_contains(&accesses, Path::new(&shebang_script_path), AccessMode::Read); - assert_contains(&accesses, Path::new("/hello"), AccessMode::Read); + assert_contains(&accesses, Path::new(&shebang_script_path), PathAccess::Read); + assert_contains(&accesses, Path::new("/hello"), PathAccess::Read); Ok(()) } @@ -80,7 +80,7 @@ async fn spawn_shebang_with_long_interpreter_path() -> anyhow::Result<()> { }) .await?; - assert_contains(&accesses, &interpreter, AccessMode::Read); + assert_contains(&accesses, &interpreter, PathAccess::Read); Ok(()) } diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index f9ccb3709..3ace70546 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -56,59 +56,59 @@ async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable #[test(tokio::test)] async fn open_read() { let accesses = track_test_bin(&["open_read", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read); } #[test(tokio::test)] async fn open_write() { let accesses = track_test_bin(&["open_write", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Write); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Write); } #[test(tokio::test)] async fn open_readwrite() { let accesses = track_test_bin(&["open_readwrite", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::ReadWrite); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::ReadWrite); } #[test(tokio::test)] async fn openat2_read() { let accesses = track_test_bin(&["openat2_read", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read); } #[test(tokio::test)] async fn openat2_write() { let accesses = track_test_bin(&["openat2_write", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Write); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Write); } #[test(tokio::test)] async fn openat2_readwrite() { let accesses = track_test_bin(&["openat2_readwrite", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::ReadWrite); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::ReadWrite); } #[test(tokio::test)] async fn open_relative() { let accesses = track_test_bin(&["open_read", "hello"], Some("/home")).await; - assert_contains(&accesses, Path::new("/home/hello"), fspy::AccessMode::Read); + assert_contains(&accesses, Path::new("/home/hello"), fspy::PathAccess::Read); } #[test(tokio::test)] async fn readdir() { let accesses = track_test_bin(&["readdir", "/home"], None).await; - assert_contains(&accesses, Path::new("/home"), fspy::AccessMode::ReadDir); + assert_contains(&accesses, Path::new("/home"), fspy::PathAccess::ReadDir); } #[test(tokio::test)] async fn stat() { let accesses = track_test_bin(&["stat", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read); } #[test(tokio::test)] async fn execve() { let accesses = track_test_bin(&["execve", "/hello"], None).await; - assert_contains(&accesses, Path::new("/hello"), fspy::AccessMode::Read); + assert_contains(&accesses, Path::new("/hello"), fspy::PathAccess::Read); } diff --git a/crates/fspy/tests/test_utils/mod.rs b/crates/fspy/tests/test_utils/mod.rs index 52fa452cb..0ae779051 100644 --- a/crates/fspy/tests/test_utils/mod.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -1,9 +1,6 @@ -use std::{ - collections::BTreeSet, - path::{Path, PathBuf, StripPrefixError}, -}; +use std::path::Path; -use fspy::{AccessMode, PathAccessIterable}; +use fspy::{IpcPath, PathAccess, PathAccessIterable}; // Used by the track_child! macro; not all test files use this macro #[doc(hidden)] #[expect( @@ -20,41 +17,42 @@ use fspy::{AccessMode, PathAccessIterable}; )] pub use vt_subprocess_test::command_for_fn; +/// Asserts that `expected_path` was accessed, and only as `expected` (a +/// variant such as `PathAccess::Read`). +/// /// # Panics /// -/// Panics if the expected path access is not found or has the wrong mode. +/// Panics if the path was not accessed, or was accessed in another way. #[track_caller] -pub fn assert_contains( - accesses: &PathAccessIterable, +pub fn assert_contains<'a>( + accesses: &'a PathAccessIterable, expected_path: &Path, - expected_mode: AccessMode, + expected: fn(&'a IpcPath) -> PathAccess<'a>, ) { - let mut actual_modes = BTreeSet::new(); + // Each access to the path, paired with what it was expected to be. + let mut path_accesses = Vec::new(); for access in accesses.iter() { - let Ok(stripped) = - access.path.strip_path_prefix::<_, Result, _>( - expected_path, - |strip_result| strip_result.map(Path::to_path_buf), - ) - else { - continue; - }; - if stripped.as_os_str().is_empty() { - actual_modes.insert(access.mode); + for path in access.paths() { + let is_expected_path = path.strip_path_prefix(expected_path, |strip_result| { + strip_result.is_ok_and(|stripped| stripped.as_os_str().is_empty()) + }); + if is_expected_path { + path_accesses.push((access, expected(path))); + } } } - if actual_modes.contains(&AccessMode::ReadDir) { + if path_accesses.iter().any(|(access, _)| matches!(access, PathAccess::ReadDir(_))) { // ReadDir already implies Read. - actual_modes.remove(&AccessMode::Read); + path_accesses.retain(|(access, _)| !matches!(access, PathAccess::Read(_))); } - assert_eq!( - BTreeSet::from([expected_mode]), - actual_modes, - "Expected to find access to path {} with mode {:?}, but it was not found in: {:?}", + assert!( + !path_accesses.is_empty() + && path_accesses.iter().all(|(access, expected)| access == expected), + "Expected accesses to {} to match, as (actual, expected): {:?}. All accesses: {:?}", expected_path.display(), - expected_mode, + path_accesses, accesses.iter().collect::>() ); } diff --git a/crates/fspy/tests/winapi.rs b/crates/fspy/tests/winapi.rs index 1b9fc125c..870507d8b 100644 --- a/crates/fspy/tests/winapi.rs +++ b/crates/fspy/tests/winapi.rs @@ -3,7 +3,7 @@ mod test_utils; use std::{ffi::OsStr, os::windows::ffi::OsStrExt, path::Path, ptr::null_mut}; -use fspy::AccessMode; +use fspy::PathAccess; use test_log::test; use test_utils::assert_contains; use winapi::um::processthreadsapi::{ @@ -36,7 +36,7 @@ async fn create_process_a() -> anyhow::Result<()> { }; }) .await?; - assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_program.exe"), AccessMode::Read); + assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_program.exe"), PathAccess::Read); Ok(()) } @@ -69,7 +69,7 @@ async fn create_process_w() -> anyhow::Result<()> { }; }) .await?; - assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_program.exe"), AccessMode::Read); + assert_contains(&accesses, Path::new("C:\\fspy_test_not_exist_program.exe"), PathAccess::Read); Ok(()) } diff --git a/crates/fspy_benchmark_launcher/src/main.rs b/crates/fspy_benchmark_launcher/src/main.rs index d3ec32637..9a2f2645c 100644 --- a/crates/fspy_benchmark_launcher/src/main.rs +++ b/crates/fspy_benchmark_launcher/src/main.rs @@ -184,8 +184,10 @@ async fn validate(target: &OsString, target_args: &[OsString], relative: bool) { .expect("failed to wait for tracked target"); assert!(termination.status.success(), "benchmark target failed: {}", termination.status); let captured_missing_access = termination.path_accesses.tracked().iter().any(|access| { - access.path.strip_path_prefix(MISSING_PATH, |result| { - result.is_ok_and(|path| path.as_os_str().is_empty()) + access.paths().any(|path| { + path.strip_path_prefix(MISSING_PATH, |result| { + result.is_ok_and(|path| path.as_os_str().is_empty()) + }) }) }); assert!(captured_missing_access, "failed to capture access to {MISSING_PATH}"); diff --git a/crates/fspy_client_unix/src/lib.rs b/crates/fspy_client_unix/src/lib.rs index a0a7d58ce..0a0869286 100644 --- a/crates/fspy_client_unix/src/lib.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -12,7 +12,7 @@ use std::{ffi::OsStr, fmt::Debug, os::unix::ffi::OsStrExt as _, path::Path}; use allocator_api2::alloc::Allocator; use convert::{ToAbsolutePath, ToAccessMode}; -use fspy_shared::ipc::{PathAccess, channel::Sender}; +use fspy_shared::ipc::{AccessMode, channel::Sender}; use fspy_shared_unix::{ exec::ExecResolveConfig, payload::{EncodedPayload, decode_payload_from_env}, @@ -71,7 +71,7 @@ impl<'a> Client<'a> { Self { encoded_payload, ipc_sender } } - fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) { + fn send(&self, mode: AccessMode, path: &Path) { let Some(ipc_sender) = &self.ipc_sender else { return; }; @@ -84,7 +84,7 @@ impl<'a> Client<'a> { } // The interception proceeds whether or not the record could be // sent — a preload library can never panic its host process. - ipc_sender.send(&PathAccess { mode, path: path.into() }); + ipc_sender.send(&mode.with_path(path.into())); } /// Resolves and reports an exec before forwarding its transformed arguments. @@ -109,8 +109,8 @@ impl<'a> Client<'a> { // SAFETY: raw_exec contains valid pointers to C strings and // null-terminated arrays, as provided by the caller. let mut exec = unsafe { raw_exec.to_exec() }; - let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| { - self.send(mode, path); + let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |path| { + self.send(AccessMode::Read, path); })?; RawExec::from_exec(exec, allocator, |raw_command| f(raw_command, pre_exec)) } diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 3054b8605..4af904666 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeSet, env::{self, args}, fs::{File, read}, io::{BufWriter, Write as _, stderr}, @@ -7,7 +7,7 @@ use std::{ process::{self, Stdio}, }; -use fspy::{AccessMode, PathAccess}; +use fspy::{IpcPath, PathAccess}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; @@ -25,25 +25,32 @@ struct Case { struct AccessCollector { dir: PathBuf, - accesses: BTreeMap>, + /// One line per distinct access, with paths relative to `dir`. + accesses: BTreeSet, } impl AccessCollector { pub const fn new(dir: PathBuf) -> Self { - Self { dir, accesses: BTreeMap::new() } + Self { dir, accesses: BTreeSet::new() } } - pub fn iter(&self) -> impl Iterator)> { - self.accesses.iter().map(|(k, v)| (k.as_str(), v)) + pub fn iter(&self) -> impl Iterator { + self.accesses.iter().map(String::as_str) } pub fn add(&mut self, access: PathAccess) { - let Some(relative_path) = access.path.strip_path_prefix(&self.dir, |result| { - result.ok().and_then(|p| p.to_str().map(str::to_owned)) - }) else { - return; + let relative = |path: &IpcPath| { + path.strip_path_prefix(&self.dir, |result| { + result.ok().and_then(|p| p.to_str().map(str::to_owned)) + }) }; - self.accesses.entry(relative_path).or_default().insert(access.mode); + let line = match access { + PathAccess::Read(path) => relative(path).map(|path| format!("{path}: Read")), + PathAccess::Write(path) => relative(path).map(|path| format!("{path}: Write")), + PathAccess::ReadWrite(path) => relative(path).map(|path| format!("{path}: ReadWrite")), + PathAccess::ReadDir(path) => relative(path).map(|path| format!("{path}: ReadDir")), + }; + self.accesses.extend(line); } } @@ -109,8 +116,8 @@ async fn main() { } let snap_file = File::create(manifest_dir.join(format!("snaps/{name}.txt"))).unwrap(); let mut snap_writer = BufWriter::new(snap_file); - for (path, modes) in collector.iter() { - writeln!(snap_writer, "{path}: {modes:?}").unwrap(); + for line in collector.iter() { + writeln!(snap_writer, "{line}").unwrap(); } } } diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index 4ca71bc3b..1cf8f591d 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -99,8 +99,7 @@ unsafe fn handle_process_image(attribute_list: PPS_ATTRIBUTE_LIST) { // Sender serialization completes before this call returns, so IpcPath does not retain // the borrowed PS_ATTRIBUTE_IMAGE_NAME buffer past the NtCreateUserProcess call. // SAFETY: accessing the global client which was initialized during DLL_PROCESS_ATTACH - unsafe { global_client() } - .send(PathAccess { mode: AccessMode::Read, path: IpcPath::from_wide(image_path) }); + unsafe { global_client() }.send(PathAccess::Read(IpcPath::from_wide(image_path))); } } @@ -294,10 +293,7 @@ unsafe fn handle_open(access_mode: impl ToAccessMode, path: impl ToAbsolutePath) let path_access = path.iter().rposition(|c| *c == u16::from(b'*')).map_or_else( || { // SAFETY: converting access mask to AccessMode via FFI-aware trait - PathAccess { - mode: access_mode.to_access_mode(), - path: IpcPath::from_wide(path), - } + access_mode.to_access_mode().with_path(IpcPath::from_wide(path)) }, |wildcard_pos| { let path_before_wildcard = &path[..wildcard_pos]; @@ -305,10 +301,7 @@ unsafe fn handle_open(access_mode: impl ToAccessMode, path: impl ToAbsolutePath) .iter() .rposition(|c| *c == u16::from(b'\\') || *c == u16::from(b'/')) .unwrap_or(0); - PathAccess { - mode: AccessMode::ReadDir, - path: IpcPath::from_wide(&path[..slash_pos]), - } + PathAccess::ReadDir(IpcPath::from_wide(&path[..slash_pos])) }, ); client.send(path_access); diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index fbf516607..0148450d2 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -376,7 +376,7 @@ mod tests { use vt_subprocess_test::command_for_fn; use super::*; - use crate::ipc::{AccessMode, IpcPath, PathAccess}; + use crate::ipc::{IpcPath, PathAccess}; /// A gibibyte, which clears the table's half of sparse address space /// and leaves the rest for payloads. None of it costs real memory @@ -449,7 +449,7 @@ mod tests { let paths = [IpcPath::from_wide(&owned[0]), IpcPath::from_wide(&owned[1])]; for path in paths { - sender.send(&PathAccess::read(path)); + sender.send(&PathAccess::Read(path)); } drop(sender); @@ -457,8 +457,7 @@ mod tests { let mut iter = frames.iter(); for path in paths { let access: PathAccess<'_> = wincode::deserialize_exact(iter.next().unwrap()).unwrap(); - assert!(access.path == path); - assert!(access.mode == AccessMode::Read); + assert!(access == PathAccess::Read(path)); } assert!(iter.next().is_none()); } diff --git a/crates/fspy_shared/src/ipc/mod.rs b/crates/fspy_shared/src/ipc/mod.rs index 1a0e8e656..443e09744 100644 --- a/crates/fspy_shared/src/ipc/mod.rs +++ b/crates/fspy_shared/src/ipc/mod.rs @@ -2,12 +2,19 @@ pub mod channel; mod ipc_path; use std::iter; +use bumpalo::Bump; pub use ipc_path::IpcPath; pub use vt_ipc_str::IpcStr; use wincode::{SchemaRead, SchemaWrite}; -#[derive(SchemaWrite, SchemaRead, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -#[wincode(tag_encoding = "u8")] +/// The kind of a single-path access, for building a record before its path +/// is resolved. +/// +/// The clients choose it from an intercepted call's arguments, resolve the +/// path, then build the record with [`AccessMode::with_path`]. It is never +/// sent: [`PathAccess`] is the record, and accesses that touch more than one +/// path build it directly. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum AccessMode { Read, Write, @@ -15,24 +22,47 @@ pub enum AccessMode { ReadDir, } +impl AccessMode { + #[must_use] + pub const fn with_path(self, path: &IpcPath) -> PathAccess<'_> { + match self { + Self::Read => PathAccess::Read(path), + Self::Write => PathAccess::Write(path), + Self::ReadWrite => PathAccess::ReadWrite(path), + Self::ReadDir => PathAccess::ReadDir(path), + } + } +} + +/// A file system access made by a traced process, with the paths it touched. +// TODO: add follow_symlinks (O_NOFOLLOW) #[derive(SchemaWrite, SchemaRead, Debug, Clone, Copy, PartialEq, Eq)] -pub struct PathAccess<'a> { - pub mode: AccessMode, - pub path: &'a IpcPath, - // TODO: add follow_symlinks (O_NOFOLLOW) +#[wincode(tag_encoding = "u8")] +pub enum PathAccess<'a> { + Read(&'a IpcPath), + Write(&'a IpcPath), + ReadWrite(&'a IpcPath), + ReadDir(&'a IpcPath), } impl<'a> PathAccess<'a> { - pub fn read(path: impl Into<&'a IpcPath>) -> Self { - Self { mode: AccessMode::Read, path: path.into() } - } - - pub fn read_dir(path: impl Into<&'a IpcPath>) -> Self { - Self { mode: AccessMode::ReadDir, path: path.into() } - } - /// The paths this access touched. pub fn paths(self) -> impl Iterator { - iter::once(self.path) + match self { + PathAccess::Read(path) + | PathAccess::Write(path) + | PathAccess::ReadWrite(path) + | PathAccess::ReadDir(path) => iter::once(path), + } + } + + #[must_use] + pub fn clone_in<'bump>(&self, bump: &'bump Bump) -> PathAccess<'bump> { + match self { + PathAccess::Read(path) => PathAccess::Read(path.clone_in(bump)), + PathAccess::Write(path) => PathAccess::Write(path.clone_in(bump)), + PathAccess::ReadWrite(path) => PathAccess::ReadWrite(path.clone_in(bump)), + PathAccess::ReadDir(path) => PathAccess::ReadDir(path.clone_in(bump)), + } } } diff --git a/crates/fspy_shared_unix/src/exec/mod.rs b/crates/fspy_shared_unix/src/exec/mod.rs index 7bef16435..207730404 100644 --- a/crates/fspy_shared_unix/src/exec/mod.rs +++ b/crates/fspy_shared_unix/src/exec/mod.rs @@ -10,7 +10,6 @@ use std::{ }; use bstr::{BStr, BString, ByteSlice}; -use fspy_shared::ipc::AccessMode; use nix::unistd::{AccessFlags, access}; use shebang::{ParseShebangOptions, parse_shebang}; @@ -102,7 +101,7 @@ impl Exec { /// - Shebang parsing fails due to I/O errors (`EIO`) pub fn resolve( &mut self, - mut on_path_access: impl FnMut(AccessMode, &Path), + mut on_path_read: impl FnMut(&Path), config: ExecResolveConfig, ) -> nix::Result<()> { if let Some(search_path) = config.search_path { @@ -119,7 +118,7 @@ impl Exec { self.program.as_ref(), path, |path| { - on_path_access(AccessMode::Read, Path::new(OsStr::from_bytes(path))); + on_path_read(Path::new(OsStr::from_bytes(path))); access(OsStr::from_bytes(path), AccessFlags::X_OK) }, |program| Ok(program.to_owned()), @@ -127,19 +126,19 @@ impl Exec { self.program = program; } - self.parse_shebang(on_path_access, config.shebang_options)?; + self.parse_shebang(on_path_read, config.shebang_options)?; Ok(()) } fn parse_shebang( &mut self, - mut on_path_access: impl FnMut(AccessMode, &Path), + mut on_path_read: impl FnMut(&Path), options: ParseShebangOptions, ) -> nix::Result<()> { if let Some(shebang) = parse_shebang( |path, buf| { - on_path_access(AccessMode::Read, path); + on_path_read(path); peek_executable(path, buf) }, Path::new(OsStr::from_bytes(&self.program)), diff --git a/crates/fspy_shared_unix/src/spawn/mod.rs b/crates/fspy_shared_unix/src/spawn/mod.rs index a4fabc0ff..4e957636a 100644 --- a/crates/fspy_shared_unix/src/spawn/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/mod.rs @@ -8,7 +8,6 @@ mod os_specific; use std::{ffi::OsStr, os::unix::ffi::OsStrExt, path::Path}; -use fspy_shared::ipc::AccessMode; #[doc(hidden)] #[cfg(target_os = "macos")] pub use os_specific::COREUTILS_FUNCTIONS as COREUTILS_FUNCTIONS_FOR_TEST; @@ -38,19 +37,19 @@ pub fn handle_exec( command: &mut Exec, config: ExecResolveConfig, encoded_payload: &EncodedPayload, - mut on_path_access: impl FnMut(AccessMode, &Path), + mut on_path_read: impl FnMut(&Path), ) -> nix::Result> { - let mut on_path_access = |mode: AccessMode, path: &Path| { + let mut on_path_read = |path: &Path| { if path.is_absolute() { - on_path_access(mode, path); + on_path_read(path); } else { let path = std::path::absolute(path).expect("Failed to get cwd"); - on_path_access(mode, &path); + on_path_read(&path); } }; - command.resolve(&mut on_path_access, config)?; - on_path_access(AccessMode::Read, Path::new(OsStr::from_bytes(&command.program))); + command.resolve(&mut on_path_read, config)?; + on_path_read(Path::new(OsStr::from_bytes(&command.program))); os_specific::handle_exec(command, encoded_payload) } diff --git a/crates/vt/src/session/execute/tracked_accesses.rs b/crates/vt/src/session/execute/tracked_accesses.rs index 91e8a22d0..2e0a05441 100644 --- a/crates/vt/src/session/execute/tracked_accesses.rs +++ b/crates/vt/src/session/execute/tracked_accesses.rs @@ -5,7 +5,7 @@ //! two configs are independent. #![cfg(fspy)] -use fspy::{AccessMode, PathAccessIterable}; +use fspy::{IpcPath, PathAccess, PathAccessIterable}; use rustc_hash::FxHashSet; use vt_path::{AbsolutePath, RelativePathBuf}; @@ -28,49 +28,46 @@ impl TrackedPathAccesses { /// files are skipped. User-configured negatives are applied by the caller /// (see module docs). pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self { + // Strip workspace root and clean `..` components in one pass. + // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`. + let relative_path = |path: &IpcPath| { + path.strip_path_prefix(workspace_root, |strip_result| { + normalize_tracked_workspace_path(strip_result.ok()?) + }) + }; + let mut accesses = Self::default(); for access in raw.iter() { - // Strip workspace root and clean `..` components in one pass. - // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`. - let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| { - let Ok(stripped_path) = strip_result else { - return None; - }; - normalize_tracked_workspace_path(stripped_path) - }); - - let Some(relative_path) = relative_path else { - continue; - }; - - match access.mode { - AccessMode::Read => { - accesses - .path_reads - .entry(relative_path) - .or_insert(PathRead { read_dir_entries: false }); + match access { + PathAccess::Read(path) => { + if let Some(path) = relative_path(path) { + accesses.add_read(path); + } } - AccessMode::Write => { - accesses.path_writes.insert(relative_path); + PathAccess::Write(path) => { + if let Some(path) = relative_path(path) { + accesses.path_writes.insert(path); + } } - AccessMode::ReadWrite => { - accesses - .path_reads - .entry(relative_path.clone()) - .or_insert(PathRead { read_dir_entries: false }); - accesses.path_writes.insert(relative_path); + PathAccess::ReadWrite(path) => { + if let Some(path) = relative_path(path) { + accesses.add_read(path.clone()); + accesses.path_writes.insert(path); + } } - AccessMode::ReadDir => { - accesses - .path_reads - .entry(relative_path) - .or_insert(PathRead { read_dir_entries: false }) - .read_dir_entries = true; + PathAccess::ReadDir(path) => { + if let Some(path) = relative_path(path) { + accesses.add_read(path).read_dir_entries = true; + } } } } accesses } + + fn add_read(&mut self, path: RelativePathBuf) -> &mut PathRead { + self.path_reads.entry(path).or_insert(PathRead { read_dir_entries: false }) + } } #[expect(