diff --git a/.cargo/config.toml b/.cargo/config.toml index ba8adcbc..f5f5d8b7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -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"] diff --git a/.cargo/zigcc-aarch64-unknown-linux-musl b/.cargo/zigcc-aarch64-unknown-linux-musl new file mode 100755 index 00000000..2c638a92 --- /dev/null +++ b/.cargo/zigcc-aarch64-unknown-linux-musl @@ -0,0 +1,2 @@ +#!/bin/sh +exec cargo-zigbuild zig cc -- -fno-sanitize=all -target aarch64-linux-musl "$@" diff --git a/.cargo/zigcc-x86_64-unknown-linux-musl b/.cargo/zigcc-x86_64-unknown-linux-musl new file mode 100755 index 00000000..51e21946 --- /dev/null +++ b/.cargo/zigcc-x86_64-unknown-linux-musl @@ -0,0 +1,2 @@ +#!/bin/sh +exec cargo-zigbuild zig cc -- -fno-sanitize=all -target x86_64-linux-musl "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87483dd4..ac3a1a86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: with: save-cache: ${{ github.ref_name == 'main' }} cache-key: test + components: clippy - run: rustup target add ${{ matrix.target }} @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/.rustfmt.toml b/.rustfmt.toml index 48704e71..c6830c71 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -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", +] diff --git a/CLAUDE.md b/CLAUDE.md index 61e20b7e..7117449f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` @@ -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`: diff --git a/Cargo.lock b/Cargo.lock index 0585ead9..939b82d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1105,6 +1105,7 @@ name = "fspy_e2e" version = "0.0.0" dependencies = [ "fspy", + "rustc-hash", "serde", "tokio", "toml", @@ -3304,6 +3305,7 @@ dependencies = [ "petgraph", "rayon", "rusqlite", + "rustc-hash", "serde", "serde_json", "thiserror 2.0.17", @@ -3330,6 +3332,7 @@ dependencies = [ "insta", "jsonc-parser", "regex", + "rustc-hash", "serde", "serde_json", "tempfile", @@ -3352,6 +3355,7 @@ dependencies = [ "monostate", "petgraph", "pretty_assertions", + "rustc-hash", "serde", "serde_json", "thiserror 2.0.17", @@ -3377,6 +3381,7 @@ dependencies = [ "futures-util", "insta", "petgraph", + "rustc-hash", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 6ca05928..b07f822a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/fspy/build.rs b/crates/fspy/build.rs index 5aacab39..8821e2c9 100644 --- a/crates/fspy/build.rs +++ b/crates/fspy/build.rs @@ -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, @@ -21,7 +28,7 @@ fn download(url: &str) -> anyhow::Result> { 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)) } @@ -50,19 +57,22 @@ fn download_and_unpack_tar_gz(url: &str, path: &str) -> anyhow::Result> 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, ), ], ), @@ -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, ), ], ), diff --git a/crates/fspy/examples/cli.rs b/crates/fspy/examples/cli.rs index b31b9f94..3ef6356f 100644 --- a/crates/fspy/examples/cli.rs +++ b/crates/fspy/examples/cli.rs @@ -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::{ @@ -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(()) } diff --git a/crates/fspy/src/arena.rs b/crates/fspy/src/arena.rs index b788e15a..727e452d 100644 --- a/crates/fspy/src/arena.rs +++ b/crates/fspy/src/arena.rs @@ -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; @@ -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() -// } -// } diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index 8e882775..e7852d7b 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -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 { 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 { @@ -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, @@ -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 { diff --git a/crates/fspy/src/error.rs b/crates/fspy/src/error.rs index 8c8f83b0..017d82a0 100644 --- a/crates/fspy/src/error.rs +++ b/crates/fspy/src/error.rs @@ -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, cwd: PathBuf, @@ -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), } diff --git a/crates/fspy/src/ipc.rs b/crates/fspy/src/ipc.rs index c2ab5332..19d7a7ce 100644 --- a/crates/fspy/src/ipc.rs +++ b/crates/fspy/src/ipc.rs @@ -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; @@ -24,7 +29,7 @@ pub struct OwnedReceiverLockGuard { impl OwnedReceiverLockGuard { pub fn lock(receiver: Receiver) -> io::Result { - 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 { diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index cfcdb78c..411112cc 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -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; diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 0c6da788..c42cf4de 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -69,10 +69,10 @@ impl SpyImpl { pub(crate) async fn spawn(&self, mut command: Command) -> Result { #[cfg(target_os = "linux")] - let supervisor = supervise::().map_err(SpawnError::SupervisorError)?; + let supervisor = supervise::().map_err(SpawnError::Supervisor)?; let (ipc_channel_conf, ipc_receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; let payload = Payload { ipc_channel_conf, @@ -98,11 +98,12 @@ impl SpyImpl { exec_resolve_accesses.add(path_access); }, ) - .map_err(|err| SpawnError::InjectionError(err.into()))?; + .map_err(|err| SpawnError::Injection(err.into()))?; command.set_exec(exec); let mut tokio_command = command.into_tokio_command(); + // SAFETY: the pre_exec closure only calls pre_exec.run() which is safe to call in a fork context unsafe { tokio_command.pre_exec(move || { if let Some(pre_exec) = pre_exec.as_ref() { @@ -117,8 +118,8 @@ impl SpyImpl { // which needs to accept incoming connections while `pre_exec` is connecting to it. let mut child = spawn_blocking(move || tokio_command.spawn()) .await - .map_err(|err| SpawnError::OsSpawnError(err.into()))? - .map_err(SpawnError::OsSpawnError)?; + .map_err(|err| SpawnError::OsSpawn(err.into()))? + .map_err(SpawnError::OsSpawn)?; Ok(TrackedChild { stdin: child.stdin.take(), @@ -133,7 +134,11 @@ impl SpyImpl { // Stop the supervisor and collect path accesses from it. #[cfg(target_os = "linux")] let arenas = arenas.chain( - supervisor.stop().await?.into_iter().map(|handler| handler.into_arena()), + supervisor + .stop() + .await? + .into_iter() + .map(syscall_handler::SyscallHandler::into_arena), ); let arenas = arenas.collect::>(); @@ -145,7 +150,7 @@ impl SpyImpl { io::Result::Ok(ChildTermination { status, path_accesses }) }) - .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result + .map(|f| f?) // flatten JoinError and io::Result .boxed(), }) } diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 5a180c90..03a4c899 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -57,20 +57,22 @@ pub struct SpyImpl { impl SpyImpl { pub fn init_in(path: &Path) -> io::Result { - let dll_path = INTERPOSE_CDYLIB.write_to(&path, ".dll").unwrap(); + let dll_path = INTERPOSE_CDYLIB.write_to(path, ".dll").unwrap(); let wide_dll_path = dll_path.as_os_str().encode_wide().collect::>(); let mut ansi_dll_path = winsafe::WideCharToMultiByte(CP::ACP, WC::NoValue, &wide_dll_path, None, None) - .map_err(|err| io::Error::from_raw_os_error(err.raw() as i32))?; + .map_err(|err| io::Error::from_raw_os_error(err.raw().cast_signed()))?; ansi_dll_path.push(0); + // SAFETY: we just pushed a NUL byte, so the slice is NUL-terminated let ansi_dll_path_with_nul = unsafe { CStr::from_bytes_with_nul_unchecked(ansi_dll_path.as_slice()) }; Ok(Self { ansi_dll_path_with_nul: ansi_dll_path_with_nul.into() }) } + #[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")] pub(crate) async fn spawn(&self, command: Command) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); let mut command = command.into_tokio_command(); @@ -78,7 +80,7 @@ impl SpyImpl { command.creation_flags(CREATE_SUSPENDED); let (channel_conf, receiver) = - channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreationError)?; + channel(SHM_CAPACITY).map_err(SpawnError::ChannelCreation)?; let mut spawn_success = false; let spawn_success = &mut spawn_success; @@ -89,8 +91,10 @@ impl SpyImpl { let mut dll_paths = ansi_dll_path_with_nul.as_ptr().cast::(); let process_handle = std_child.as_raw_handle().cast::(); + // SAFETY: process_handle is a valid handle to the just-spawned child process, + // dll_paths points to a valid null-terminated ANSI string let success = - unsafe { DetourUpdateProcessWithDll(process_handle, &mut dll_paths, 1) }; + unsafe { DetourUpdateProcessWithDll(process_handle, &raw mut dll_paths, 1) }; if success != TRUE { return Err(io::Error::last_os_error()); } @@ -100,6 +104,8 @@ impl SpyImpl { ansi_dll_path_with_nul: ansi_dll_path_with_nul.to_bytes(), }; let payload_bytes = bincode::encode_to_vec(payload, BINCODE_CONFIG).unwrap(); + // SAFETY: process_handle is valid, PAYLOAD_ID is a static GUID, + // payload_bytes is a valid buffer with correct length let success = unsafe { DetourCopyPayloadToProcess( process_handle, @@ -113,8 +119,10 @@ impl SpyImpl { } let main_thread_handle = std_child.main_thread_handle(); + // SAFETY: main_thread_handle is a valid thread handle from the spawned child let resume_thread_ret = - unsafe { ResumeThread(main_thread_handle.as_raw_handle().cast()) } as i32; + unsafe { ResumeThread(main_thread_handle.as_raw_handle().cast()) } + .cast_signed(); if resume_thread_ret == -1 { return Err(io::Error::last_os_error()); @@ -123,11 +131,7 @@ impl SpyImpl { Ok(std_child) }) .map_err(|err| { - if !*spawn_success { - SpawnError::InjectionError(err.into()) - } else { - SpawnError::OsSpawnError(err.into()) - } + if *spawn_success { SpawnError::OsSpawn(err) } else { SpawnError::Injection(err) } })?; Ok(TrackedChild { @@ -145,7 +149,7 @@ impl SpyImpl { io::Result::Ok(ChildTermination { status, path_accesses }) }) - .map(|f| io::Result::Ok(f??)) // flatten JoinError and io::Result + .map(|f| f?) // flatten JoinError and io::Result .boxed(), }) } diff --git a/crates/fspy/tests/node_fs.rs b/crates/fspy/tests/node_fs.rs index bbbb2bb5..0c03972c 100644 --- a/crates/fspy/tests/node_fs.rs +++ b/crates/fspy/tests/node_fs.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + mod test_utils; use std::{ diff --git a/crates/fspy/tests/oxlint.rs b/crates/fspy/tests/oxlint.rs index 73053d02..11454fe5 100644 --- a/crates/fspy/tests/oxlint.rs +++ b/crates/fspy/tests/oxlint.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + mod test_utils; use std::{env::vars_os, ffi::OsString}; @@ -33,7 +40,7 @@ async fn track_oxlint(dir: &std::path::Path, args: &[&str]) -> anyhow::Result anyhow::Result<()> { let ts_file = tmpdir_path.join("index.ts"); std::fs::write( &ts_file, - r#" + r" import type { Foo } from './types'; declare const _foo: Foo; -"#, +", )?; // Run oxlint without --type-aware first diff --git a/crates/fspy/tests/rust_std.rs b/crates/fspy/tests/rust_std.rs index 633ec11b..7aca02e5 100644 --- a/crates/fspy/tests/rust_std.rs +++ b/crates/fspy/tests/rust_std.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + mod test_utils; use std::{ diff --git a/crates/fspy/tests/rust_tokio.rs b/crates/fspy/tests/rust_tokio.rs index 7e13ea36..55f89cd3 100644 --- a/crates/fspy/tests/rust_tokio.rs +++ b/crates/fspy/tests/rust_tokio.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + mod test_utils; use std::{env::current_dir, process::Stdio}; diff --git a/crates/fspy/tests/shebang.rs b/crates/fspy/tests/shebang.rs index 52f2d8d7..1d2c9d0d 100644 --- a/crates/fspy/tests/shebang.rs +++ b/crates/fspy/tests/shebang.rs @@ -1,4 +1,10 @@ #![cfg(unix)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] mod test_utils; diff --git a/crates/fspy/tests/static_executable.rs b/crates/fspy/tests/static_executable.rs index 57059c6d..52ede928 100644 --- a/crates/fspy/tests/static_executable.rs +++ b/crates/fspy/tests/static_executable.rs @@ -1,4 +1,10 @@ #![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] use std::{ fs::{self, Permissions}, @@ -20,7 +26,7 @@ const TEST_BIN_CONTENT: &[u8] = include_bytes!(env!("CARGO_BIN_FILE_FSPY_TEST_BI fn test_bin_path() -> &'static Path { static TEST_BIN_PATH: LazyLock = LazyLock::new(|| { assert_eq!( - is_dynamically_linked_to_libc(&TEST_BIN_CONTENT), + is_dynamically_linked_to_libc(TEST_BIN_CONTENT), Ok(false), "Test binary is not a static executable" ); @@ -40,7 +46,7 @@ async fn track_test_bin(args: &[&str], cwd: Option<&str>) -> PathAccessIterable let mut cmd = fspy::Command::new(test_bin_path()); if let Some(cwd) = cwd { cmd.current_dir(cwd); - }; + } cmd.args(args); let tracked_child = cmd.spawn().await.unwrap(); diff --git a/crates/fspy/tests/test_utils.rs b/crates/fspy/tests/test_utils/mod.rs similarity index 65% rename from crates/fspy/tests/test_utils.rs rename to crates/fspy/tests/test_utils/mod.rs index f13ab520..5b14c1ea 100644 --- a/crates/fspy/tests/test_utils.rs +++ b/crates/fspy/tests/test_utils/mod.rs @@ -1,10 +1,25 @@ use std::path::{Path, PathBuf, StripPrefixError}; use fspy::{AccessMode, PathAccessIterable}; +// Used by the track_child! macro; not all test files use this macro #[doc(hidden)] -#[allow(unused)] +#[expect( + clippy::useless_attribute, + reason = "allow attribute on re-export required for macro usage" +)] +#[expect( + clippy::allow_attributes, + reason = "allow attribute on re-export required for macro usage" +)] +#[allow( + unused_imports, + reason = "used by track_child! macro; not all test files use this macro" +)] pub use fspy_test_utils::command_executing; +/// # Panics +/// +/// Panics if the expected path access is not found or has the wrong mode. #[track_caller] pub fn assert_contains( accesses: &PathAccessIterable, @@ -34,8 +49,8 @@ pub fn assert_contains( assert_eq!( expected_mode, actual_mode, - "Expected to find access to path {:?} with mode {:?}, but it was not found in: {:?}", - expected_path, + "Expected to find access to path {} with mode {:?}, but it was not found in: {:?}", + expected_path.display(), expected_mode, accesses.iter().collect::>() ); @@ -49,8 +64,13 @@ macro_rules! track_child { }}; } +// Used by the track_child! macro; not all test files use this macro #[doc(hidden)] -#[allow(unused)] +#[expect( + clippy::allow_attributes, + reason = "allow attribute required for conditionally-used helper" +)] +#[allow(dead_code, reason = "used by track_child! macro; not all test files use this macro")] pub async fn spawn_std(std_cmd: std::process::Command) -> anyhow::Result { let mut command = fspy::Command::new(std_cmd.get_program()); command diff --git a/crates/fspy/tests/winapi.rs b/crates/fspy/tests/winapi.rs index 232df7bd..5f4ec6e0 100644 --- a/crates/fspy/tests/winapi.rs +++ b/crates/fspy/tests/winapi.rs @@ -1,4 +1,10 @@ #![cfg(windows)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] mod test_utils; @@ -14,8 +20,11 @@ use winapi::um::processthreadsapi::{ #[test(tokio::test)] async fn create_process_a() -> anyhow::Result<()> { let accesses = track_child!((), |(): ()| { + // SAFETY: zeroing STARTUPINFOA is valid for the Windows API let mut si: STARTUPINFOA = unsafe { std::mem::zeroed() }; + // SAFETY: zeroing PROCESS_INFORMATION is valid for the Windows API let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: all pointers are valid or null_mut as required by CreateProcessA unsafe { CreateProcessA( c"C:\\fspy_test_not_exist_program.exe".as_ptr().cast(), @@ -26,8 +35,8 @@ async fn create_process_a() -> anyhow::Result<()> { 0, null_mut(), null_mut(), - &mut si, - &mut pi, + &raw mut si, + &raw mut pi, ) }; }) @@ -40,10 +49,13 @@ async fn create_process_a() -> anyhow::Result<()> { #[test(tokio::test)] async fn create_process_w() -> anyhow::Result<()> { let accesses = track_child!((), |(): ()| { + // SAFETY: zeroing STARTUPINFOW is valid for the Windows API let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + // SAFETY: zeroing PROCESS_INFORMATION is valid for the Windows API let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; let program = OsStr::new("C:\\fspy_test_not_exist_program.exe\0").encode_wide().collect::>(); + // SAFETY: all pointers are valid or null_mut as required by CreateProcessW unsafe { CreateProcessW( program.as_ptr(), @@ -54,8 +66,8 @@ async fn create_process_w() -> anyhow::Result<()> { 0, null_mut(), null_mut(), - &mut si, - &mut pi, + &raw mut si, + &raw mut pi, ) }; }) diff --git a/crates/fspy_detours_sys/src/lib.rs b/crates/fspy_detours_sys/src/lib.rs index 46396b47..dcd3c721 100644 --- a/crates/fspy_detours_sys/src/lib.rs +++ b/crates/fspy_detours_sys/src/lib.rs @@ -1,6 +1,13 @@ #![cfg(windows)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + clippy::wildcard_imports, + reason = "non-vite crate; generated FFI bindings use wildcard imports" +)] -#[allow(non_camel_case_types, non_snake_case)] +#[expect(non_camel_case_types, non_snake_case, reason = "generated FFI bindings")] #[rustfmt::skip] // generated code is formatted by prettyplease, not rustfmt mod generated_bindings; diff --git a/crates/fspy_detours_sys/tests/bindings.rs b/crates/fspy_detours_sys/tests/bindings.rs index 24d97262..4382eb58 100644 --- a/crates/fspy_detours_sys/tests/bindings.rs +++ b/crates/fspy_detours_sys/tests/bindings.rs @@ -1,4 +1,10 @@ #![cfg(windows)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] use std::{env, fs}; diff --git a/crates/fspy_e2e/Cargo.toml b/crates/fspy_e2e/Cargo.toml index 554df534..db8833ee 100644 --- a/crates/fspy_e2e/Cargo.toml +++ b/crates/fspy_e2e/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] fspy = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } toml = { workspace = true } diff --git a/crates/fspy_e2e/src/main.rs b/crates/fspy_e2e/src/main.rs index 7091858b..7e5afc7c 100644 --- a/crates/fspy_e2e/src/main.rs +++ b/crates/fspy_e2e/src/main.rs @@ -1,5 +1,12 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + use std::{ - collections::{BTreeMap, HashMap, btree_map::Entry}, + collections::{BTreeMap, btree_map::Entry}, env::{self, args}, fs::{File, read}, io::{BufWriter, Write as _, stderr}, @@ -8,12 +15,13 @@ use std::{ }; use fspy::{AccessMode, PathAccess}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; #[derive(Serialize, Deserialize)] struct Config { - cases: HashMap, + cases: FxHashMap, } #[derive(Serialize, Deserialize)] @@ -55,6 +63,11 @@ impl AccessCollector { } #[tokio::main] +#[expect( + clippy::print_stdout, + clippy::print_stderr, + reason = "CLI tool that outputs results and errors to stdout/stderr" +)] async fn main() { let mut args = args(); args.next(); // skip the first argument (the program name) diff --git a/crates/fspy_preload_unix/src/client/convert.rs b/crates/fspy_preload_unix/src/client/convert.rs index e20df867..c42854b3 100644 --- a/crates/fspy_preload_unix/src/client/convert.rs +++ b/crates/fspy_preload_unix/src/client/convert.rs @@ -15,8 +15,8 @@ use nix::unistd::getcwd; fn get_fd_path(fd: RawFd) -> nix::Result> { if fd == libc::AT_FDCWD { return Ok(Some(getcwd()?)); - }; - match nix::fcntl::readlink(CString::new(format!("/proc/self/fd/{}", fd)).unwrap().as_c_str()) { + } + match nix::fcntl::readlink(CString::new(format!("/proc/self/fd/{fd}")).unwrap().as_c_str()) { Ok(path) => Ok(Some(path.into())), Err(nix::Error::EBADF | nix::Error::ENOENT) => Ok(None), // invalid fd or no such file (Most likely a stdio fd) Err(e) => Err(e), @@ -30,6 +30,7 @@ fn get_fd_path(fd: RawFd) -> nix::Result> { } let mut path = std::path::PathBuf::new(); match nix::fcntl::fcntl( + // SAFETY: fd is a valid file descriptor provided by the caller, and the borrow does not outlive this function call unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }, nix::fcntl::FcntlArg::F_GETPATH(&mut path), ) { @@ -64,6 +65,7 @@ impl ToAbsolutePath for PathAt { self, f: F, ) -> nix::Result { + // SAFETY: self.1 is a non-null pointer to a valid null-terminated C string, as guaranteed by the libc calling convention let pathname = unsafe { CStr::from_ptr(self.1) }.to_bytes().as_bstr(); if pathname.first().copied() == Some(b'/') { @@ -85,6 +87,7 @@ impl ToAbsolutePath for *const c_char { self, f: F, ) -> nix::Result { + // SAFETY: delegates to PathAt::to_absolute_path with AT_FDCWD and the caller-provided C string pointer unsafe { PathAt(libc::AT_FDCWD, self).to_absolute_path(f) } } } @@ -113,6 +116,7 @@ impl ToAccessMode for OpenFlags { pub struct ModeStr(pub *const c_char); impl ToAccessMode for ModeStr { unsafe fn to_access_mode(self) -> AccessMode { + // SAFETY: self.0 is a non-null pointer to a valid null-terminated C string, as guaranteed by the libc calling convention let mode_str = unsafe { CStr::from_ptr(self.0) }.to_bytes().as_bstr(); let has_read = mode_str.contains(&b'r'); let has_write = mode_str.contains(&b'w') || mode_str.contains(&b'a'); diff --git a/crates/fspy_preload_unix/src/client/mod.rs b/crates/fspy_preload_unix/src/client/mod.rs index 7a73c974..1604f25c 100644 --- a/crates/fspy_preload_unix/src/client/mod.rs +++ b/crates/fspy_preload_unix/src/client/mod.rs @@ -20,8 +20,10 @@ pub struct Client { ipc_sender: Option, } +// SAFETY: Client fields are only mutated during initialization in the ctor; after that, all access is read-only #[cfg(target_os = "macos")] unsafe impl Sync for Client {} +// SAFETY: Client is only sent once during initialization; after that it lives in a static OnceLock #[cfg(target_os = "macos")] unsafe impl Send for Client {} @@ -32,6 +34,10 @@ impl Debug for Client { } impl Client { + #[expect( + clippy::print_stderr, + reason = "preload library intentionally uses stderr for error reporting" + )] #[cfg(not(test))] fn from_env() -> Self { use fspy_shared_unix::payload::decode_payload_from_env; @@ -44,12 +50,12 @@ impl Client { // this can happen if the process is started after the root target process has exited. // By that time the channel would have been closed in the receiver side. // In this case we just leave a message and skip sending any path accesses. - eprintln!("fspy: failed to create ipc sender: {}", err); + eprintln!("fspy: failed to create ipc sender: {err}"); None } }; - Self { ipc_sender, encoded_payload } + Self { encoded_payload, ipc_sender } } fn send(&self, path_access: PathAccess<'_>) -> anyhow::Result<()> { @@ -85,6 +91,7 @@ impl Client { raw_exec: RawExec, f: impl FnOnce(RawExec, Option) -> nix::Result, ) -> nix::Result { + // 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, |path_access| { self.send(path_access).unwrap(); @@ -97,7 +104,9 @@ impl Client { path: impl ToAbsolutePath, mode: impl ToAccessMode, ) -> anyhow::Result<()> { + // SAFETY: mode contains a valid pointer (if ModeStr) or a plain value, as provided by the caller let mode = unsafe { mode.to_access_mode() }; + // SAFETY: path contains valid pointers to C strings/file descriptors, as provided by the caller let () = unsafe { path.to_absolute_path(|abs_path| { let Some(abs_path) = abs_path else { @@ -119,6 +128,7 @@ pub fn global_client() -> Option<&'static Client> { pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) { if let Some(client) = global_client() { + // SAFETY: path and mode contain valid pointers/values forwarded from the interposed function's caller unsafe { client.try_handle_open(path, mode) }.unwrap(); } } diff --git a/crates/fspy_preload_unix/src/client/raw_exec.rs b/crates/fspy_preload_unix/src/client/raw_exec.rs index 82403c49..250f9e28 100644 --- a/crates/fspy_preload_unix/src/client/raw_exec.rs +++ b/crates/fspy_preload_unix/src/client/raw_exec.rs @@ -17,14 +17,18 @@ impl RawExec { ) -> Vec { let mut count = 0usize; let mut cur_str = strs; + // SAFETY: cur_str points into a valid null-terminated array of C string pointers (argv/envp convention) while !(unsafe { *cur_str }).is_null() { count += 1; + // SAFETY: advancing within the bounds of the null-terminated pointer array cur_str = unsafe { cur_str.add(1) }; } let mut str_vec = Vec::::with_capacity(count); for i in 0..count { + // SAFETY: i < count, so strs.add(i) is within the bounds of the pointer array let cur_str = unsafe { strs.add(i) }; + // SAFETY: *cur_str is a non-null pointer to a valid null-terminated C string (verified by the counting loop above) str_vec.push(map_fn(unsafe { CStr::from_ptr(*cur_str) }.to_bytes().as_bstr())); } str_vec @@ -48,18 +52,20 @@ impl RawExec { f(ptr_vec.as_ptr()) } - pub unsafe fn to_exec<'a>(self) -> Exec { + pub unsafe fn to_exec(self) -> Exec { + // SAFETY: self.prog is a non-null pointer to a valid null-terminated C string, as guaranteed by the libc exec calling convention let program = unsafe { CStr::from_ptr(self.prog) }.to_bytes().as_bstr().to_owned(); + // SAFETY: self.argv is a valid null-terminated array of C string pointers, as guaranteed by the libc exec calling convention let args = unsafe { Self::collect_c_str_array(self.argv, BStr::to_owned) }; + // SAFETY: self.envp is a valid null-terminated array of C string pointers, as guaranteed by the libc exec calling convention let envs = unsafe { Self::collect_c_str_array(self.envp, |env| { - if let Some(eq_pos) = env.iter().position(|b| *b == b'=') { - (env[..eq_pos].to_owned(), Some(env[(eq_pos + 1)..].to_owned())) - } else { - (env.to_owned(), None) - } + env.iter().position(|b| *b == b'=').map_or_else( + || (env.to_owned(), None), + |eq_pos| (env[..eq_pos].to_owned(), Some(env[(eq_pos + 1)..].to_owned())), + ) }) }; diff --git a/crates/fspy_preload_unix/src/interceptions/access.rs b/crates/fspy_preload_unix/src/interceptions/access.rs index 945d7970..0cae3922 100644 --- a/crates/fspy_preload_unix/src/interceptions/access.rs +++ b/crates/fspy_preload_unix/src/interceptions/access.rs @@ -8,9 +8,11 @@ use crate::{ intercept!(access(64): unsafe extern "C" fn(pathname: *const c_char, mode: c_int) -> c_int); unsafe extern "C" fn access(pathname: *const c_char, mode: c_int) -> c_int { + // SAFETY: pathname is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(pathname, AccessMode::READ); } + // SAFETY: calling the original libc access() with the same arguments forwarded from the interposed function unsafe { access::original()(pathname, mode) } } @@ -21,8 +23,10 @@ unsafe extern "C" fn faccessat( mode: c_int, flags: c_int, ) -> c_int { + // SAFETY: dirfd and pathname are valid arguments provided by the caller of the interposed function unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ); } + // SAFETY: calling the original libc faccessat() with the same arguments forwarded from the interposed function unsafe { faccessat::original()(dirfd, pathname, mode, flags) } } diff --git a/crates/fspy_preload_unix/src/interceptions/dirent.rs b/crates/fspy_preload_unix/src/interceptions/dirent.rs index c3a3584c..c5a9b412 100644 --- a/crates/fspy_preload_unix/src/interceptions/dirent.rs +++ b/crates/fspy_preload_unix/src/interceptions/dirent.rs @@ -18,7 +18,9 @@ unsafe extern "C" fn scandir( select: *const c_void, compar: *const c_void, ) -> c_int { + // SAFETY: dirname is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(dirname, AccessMode::READ_DIR) } + // SAFETY: calling the original libc scandir() with the same arguments forwarded from the interposed function unsafe { scandir::original()(dirname, namelist, select, compar) } } @@ -37,7 +39,9 @@ mod macos_only { select: *const c_void, compar: *const c_void, ) -> c_int { + // SAFETY: dirname is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(dirname, AccessMode::READ_DIR) }; + // SAFETY: calling the original libc scandir_b() with the same arguments forwarded from the interposed function unsafe { scandir_b::original()(dirname, namelist, select, compar) } } } @@ -49,18 +53,24 @@ unsafe extern "C" fn getdirentries( nbytes: c_int, basep: *mut c_long, ) -> c_int { + // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function unsafe { handle_open(Fd(fd), AccessMode::READ_DIR) }; + // SAFETY: calling the original libc getdirentries() with the same arguments forwarded from the interposed function unsafe { getdirentries::original()(fd, buf, nbytes, basep) } } intercept!(fdopendir(64): unsafe extern "C" fn (fd: c_int) -> *mut DIR); unsafe extern "C" fn fdopendir(fd: c_int) -> *mut DIR { + // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function unsafe { handle_open(Fd(fd), AccessMode::READ_DIR) }; + // SAFETY: calling the original libc fdopendir() with the same arguments forwarded from the interposed function unsafe { fdopendir::original()(fd) } } intercept!(opendir(64): unsafe extern "C" fn (*const c_char) -> *mut DIR); unsafe extern "C" fn opendir(dir_name: *const c_char) -> *mut DIR { + // SAFETY: dir_name is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(dir_name, AccessMode::READ_DIR) }; + // SAFETY: calling the original libc opendir() with the same arguments forwarded from the interposed function unsafe { opendir::original()(dir_name) } } diff --git a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs index 8ac348e3..f368a838 100644 --- a/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs +++ b/crates/fspy_preload_unix/src/interceptions/linux_syscall.rs @@ -9,23 +9,32 @@ use crate::{ intercept!(syscall(64): unsafe extern "C" fn(c_long, args: ...) -> c_long); unsafe extern "C" fn syscall(syscall_no: c_long, mut args: ...) -> c_long { // https://github.com/bminor/glibc/blob/efc8642051e6c4fe5165e8986c1338ba2c180de6/sysdeps/unix/sysv/linux/syscall.c#L23 + // SAFETY: extracting variadic arguments matching the syscall ABI; the caller passes at least 6 c_long arguments let a0 = unsafe { args.arg::() }; + // SAFETY: extracting variadic arguments matching the syscall ABI let a1 = unsafe { args.arg::() }; + // SAFETY: extracting variadic arguments matching the syscall ABI let a2 = unsafe { args.arg::() }; + // SAFETY: extracting variadic arguments matching the syscall ABI let a3 = unsafe { args.arg::() }; + // SAFETY: extracting variadic arguments matching the syscall ABI let a4 = unsafe { args.arg::() }; + // SAFETY: extracting variadic arguments matching the syscall ABI let a5 = unsafe { args.arg::() }; - match syscall_no { - libc::SYS_statx => { - // c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD) - let dirfd = a0 as c_int; - let pathname = a1 as *const c_char; - unsafe { - handle_open(PathAt(dirfd, pathname), AccessMode::READ); - } + if syscall_no == libc::SYS_statx { + // c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD) + #[expect( + clippy::cast_possible_truncation, + reason = "c-style conversion is expected: (4294967196 -> -100 aka libc::AT_FDCWD)" + )] + let dirfd = a0 as c_int; + let pathname = a1 as *const c_char; + // SAFETY: pathname is a valid pointer to a null-terminated C string provided via the syscall arguments + unsafe { + handle_open(PathAt(dirfd, pathname), AccessMode::READ); } - _ => {} } + // SAFETY: forwarding the syscall to the original libc syscall function with the extracted arguments unsafe { syscall::original()(syscall_no, a0, a1, a2, a3, a4, a5) } } diff --git a/crates/fspy_preload_unix/src/interceptions/open.rs b/crates/fspy_preload_unix/src/interceptions/open.rs index 0e0cbd0b..41c5d653 100644 --- a/crates/fspy_preload_unix/src/interceptions/open.rs +++ b/crates/fspy_preload_unix/src/interceptions/open.rs @@ -27,11 +27,15 @@ type Mode = c_int; intercept!(open(64): unsafe extern "C" fn(*const c_char, c_int, args: ...) -> c_int); unsafe extern "C" fn open(path: *const c_char, flags: c_int, mut args: ...) -> c_int { + // SAFETY: path is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(path, OpenFlags(flags)) }; if has_mode_arg(flags) { + // SAFETY: when O_CREAT or O_TMPFILE is set, a mode_t argument is required by the open() contract let mode: Mode = unsafe { args.arg() }; + // SAFETY: calling the original libc open() with the same arguments forwarded from the interposed function unsafe { open::original()(path, flags, mode) } } else { + // SAFETY: calling the original libc open() with the same arguments forwarded from the interposed function unsafe { open::original()(path, flags) } } } @@ -43,20 +47,26 @@ unsafe extern "C" fn openat( flags: c_int, mut args: ... ) -> c_int { + // SAFETY: dirfd and path are valid arguments provided by the caller of the interposed function unsafe { handle_open(PathAt(dirfd, path), OpenFlags(flags)) }; if has_mode_arg(flags) { // https://github.com/tailhook/openat/issues/21#issuecomment-535914957 + // SAFETY: when O_CREAT or O_TMPFILE is set, a mode_t argument is required by the openat() contract let mode: Mode = unsafe { args.arg() }; + // SAFETY: calling the original libc openat() with the same arguments forwarded from the interposed function unsafe { openat::original()(dirfd, path, flags, mode) } } else { + // SAFETY: calling the original libc openat() with the same arguments forwarded from the interposed function unsafe { openat::original()(dirfd, path, flags) } } } intercept!(fopen(64): unsafe extern "C" fn(path: *const c_char, mode: *const c_char) -> *mut FILE); unsafe extern "C" fn fopen(path: *const c_char, mode: *const c_char) -> *mut libc::FILE { + // SAFETY: path and mode are valid C string pointers provided by the caller of the interposed function unsafe { handle_open(path, ModeStr(mode)) }; + // SAFETY: calling the original libc fopen() with the same arguments forwarded from the interposed function unsafe { fopen::original()(path, mode) } } @@ -66,6 +76,8 @@ unsafe extern "C" fn freopen( mode: *const c_char, stream: *mut FILE, ) -> *mut FILE { + // SAFETY: path and mode are valid C string pointers provided by the caller of the interposed function unsafe { handle_open(path, ModeStr(mode)) }; + // SAFETY: calling the original libc freopen() with the same arguments forwarded from the interposed function unsafe { freopen::original()(path, mode, stream) } } diff --git a/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs b/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs index ecaa9649..09d7b7bc 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/exec/mod.rs @@ -14,6 +14,7 @@ use crate::{ #[cfg(target_os = "macos")] pub unsafe fn environ() -> *const *const c_char { + // SAFETY: _NSGetEnviron() always returns a valid pointer to the process's environ on macOS unsafe { *(libc::_NSGetEnviron().cast()) } } @@ -22,6 +23,7 @@ pub unsafe fn environ() -> *const *const c_char { unsafe extern "C" { static environ: *const *const c_char; } + // SAFETY: environ is a valid global pointer to the process environment, as defined by POSIX unsafe { environ } } @@ -33,6 +35,7 @@ fn handle_exec( ) -> libc::c_int { let client = global_client().expect("exec unexpectedly called before client initialized in ctor"); + // SAFETY: prog, argv, and envp are valid pointers to C strings/arrays forwarded from the interposed exec function let result = unsafe { client.handle_exec(config, RawExec { prog, argv, envp }, |raw_command, pre_exec| { if let Some(pre_exec) = pre_exec { @@ -65,7 +68,12 @@ unsafe extern "C" fn execve( intercept!(execl(64): unsafe extern "C" fn(path: *const c_char, arg0: *const c_char, ...) -> c_int); unsafe extern "C" fn execl(path: *const c_char, arg0: *const c_char, valist: ...) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execl::original; + // SAFETY: valist and arg0 are valid variadic arguments forwarded from the interposed execl function unsafe { with_argv(valist, arg0, |args, _remaining| { handle_exec(ExecResolveConfig::search_path_disabled(), path, args.as_ptr(), environ()) @@ -75,7 +83,12 @@ unsafe extern "C" fn execl(path: *const c_char, arg0: *const c_char, valist: ... intercept!(execlp(64): unsafe extern "C" fn(path: *const c_char, arg0: *const c_char, ...) -> c_int); unsafe extern "C" fn execlp(path: *const c_char, arg0: *const c_char, valist: ...) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execlp::original; + // SAFETY: valist and arg0 are valid variadic arguments forwarded from the interposed execlp function unsafe { with_argv(valist, arg0, |args, _remaining| { handle_exec( @@ -90,7 +103,12 @@ unsafe extern "C" fn execlp(path: *const c_char, arg0: *const c_char, valist: .. intercept!(execle(64): unsafe extern "C" fn(path: *const c_char, arg0: *const c_char, ...) -> c_int); unsafe extern "C" fn execle(path: *const c_char, arg0: *const c_char, valist: ...) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execle::original; + // SAFETY: valist and arg0 are valid variadic arguments forwarded from the interposed execle function unsafe { with_argv(valist, arg0, |args, mut remaining| { let envp = remaining.arg::<*const *const c_char>(); @@ -101,7 +119,12 @@ unsafe extern "C" fn execle(path: *const c_char, arg0: *const c_char, valist: .. intercept!(execv(64): unsafe extern "C" fn(path: *const c_char, argv: *const *const c_char) -> c_int); unsafe extern "C" fn execv(path: *const c_char, argv: *const *const c_char) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execv::original; + // SAFETY: path, argv are valid pointers forwarded from the interposed function; environ() returns the process environment unsafe { handle_exec(ExecResolveConfig::search_path_disabled(), path, argv, environ()) } } @@ -110,14 +133,29 @@ intercept!(execvp(64): unsafe extern "C" fn( argv: *const *const libc::c_char, ) -> c_int); unsafe extern "C" fn execvp(prog: *const c_char, argv: *const *const c_char) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execvp::original; + // SAFETY: environ() returns the valid process environment pointer handle_exec(ExecResolveConfig::search_path_enabled(None), prog, argv, unsafe { environ() }) } #[cfg(target_os = "linux")] mod linux_only { - use std::ops::Deref; - + #[expect( + clippy::useless_attribute, + reason = "allow_attributes on use items is flagged as useless but needed here" + )] + #[expect( + clippy::allow_attributes, + reason = "using allow because wildcard_imports may or may not fire depending on build target" + )] + #[allow( + clippy::wildcard_imports, + reason = "macro-generated code requires types from parent scope" + )] use super::*; use crate::client::convert::{PathAt, ToAbsolutePath}; @@ -131,6 +169,10 @@ mod linux_only { argv: *const *const libc::c_char, envp: *const *const libc::c_char, ) -> c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execvpe::original; handle_exec(ExecResolveConfig::search_path_enabled(None), file, argv, envp) } @@ -148,17 +190,23 @@ mod linux_only { envp: *const *mut libc::c_char, flags: c_int, // TODO: conform to semantics of flags ) -> libc::c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = execveat::original; + // SAFETY: PathAt wraps a valid dirfd and pathname pointer from the interposed execveat call let abs_path_result = unsafe { PathAt(dirfd, pathname).to_absolute_path(|path| { let Some(path) = path else { return Ok(None); }; - Ok(Some(CString::new(path.deref()).unwrap())) + Ok(Some(CString::new(&**path).unwrap())) }) }; let abs_path = match abs_path_result { Ok(None) => { + // SAFETY: forwarding the original arguments to the real execveat syscall return unsafe { execveat::original()(dirfd, pathname, argv, envp, flags) }; } Ok(Some(path)) => path.as_ptr(), @@ -180,8 +228,12 @@ mod linux_only { argv: *const *const libc::c_char, envp: *const *const libc::c_char, ) -> libc::c_int { + #[expect( + clippy::no_effect_underscore_binding, + reason = "suppresses unused warning on *::original" + )] let _unused = fexecve::original; - let prog = format!("/proc/self/fd/{}\0", fd); + let prog = format!("/proc/self/fd/{fd}\0"); let prog = prog.as_ptr(); handle_exec(ExecResolveConfig::search_path_disabled(), prog.cast(), argv, envp) } diff --git a/crates/fspy_preload_unix/src/interceptions/spawn/exec/with_argv.rs b/crates/fspy_preload_unix/src/interceptions/spawn/exec/with_argv.rs index ff29de52..e5d41d06 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/exec/with_argv.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/exec/with_argv.rs @@ -1,6 +1,6 @@ use std::{ ffi::VaList, - mem::{self, MaybeUninit, transmute}, + mem::{self, MaybeUninit}, slice, }; @@ -8,6 +8,7 @@ use libc::{c_char, c_int}; use nix::Error; // https://github.com/redox-os/relibc/blob/710911febb07a43716a6236cc9e5b864e227e36e/src/header/unistd/mod.rs#L1094 +#[expect(clippy::similar_names, reason = "arg0 and argc are standard C naming conventions")] pub unsafe fn with_argv( mut va: VaList, arg0: *const c_char, @@ -30,11 +31,13 @@ pub unsafe fn with_argv( stack.as_mut_slice() } else if argc < 4096 { // TODO: Use ARG_MAX, not this hardcoded constant + // SAFETY: requesting a heap allocation of the correct size for argc pointers let ptr = unsafe { libc::malloc(argc * mem::size_of::<*const c_char>()) }; if ptr.is_null() { Error::ENOMEM.set(); return -1; } + // SAFETY: ptr is non-null (checked above), properly aligned, and points to argc elements worth of allocated memory unsafe { slice::from_raw_parts_mut(ptr.cast::>(), argc) } } else { Error::E2BIG.set(); @@ -42,17 +45,21 @@ pub unsafe fn with_argv( }; out[0].write(arg0); - for i in 1..argc { - out[i].write(unsafe { va.arg::<*const c_char>() }); + for item in out.iter_mut().take(argc).skip(1) { + // SAFETY: extracting the next *const c_char argument from the va_list; the count was pre-validated + item.write(unsafe { va.arg::<*const c_char>() }); } out[argc].write(core::ptr::null()); - // NULL + // SAFETY: consuming the NULL terminator from the va_list to advance past it unsafe { va.arg::<*const c_char>() }; - f(unsafe { transmute::<&[MaybeUninit<*const c_char>], &[*const c_char]>(&*out) }, va); + // Safety: MaybeUninit<*const c_char> has the same layout as *const c_char, + // and all elements have been initialized via write() above. + f(unsafe { &*(&raw const *out as *const [*const c_char]) }, va); // f only returns if it fails if argc >= 32 { + // SAFETY: out was allocated with libc::malloc above (argc >= 32 branch), so it must be freed with libc::free unsafe { libc::free(out.as_mut_ptr().cast()) }; } -1 diff --git a/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs b/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs index 3dd6f0ad..9496b115 100644 --- a/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs +++ b/crates/fspy_preload_unix/src/interceptions/spawn/posix_spawn.rs @@ -17,6 +17,10 @@ type PosixSpawnFn = unsafe extern "C" fn( envp: *const *mut c_char, ) -> libc::c_int; +#[expect( + clippy::too_many_arguments, + reason = "mirrors the posix_spawn(3) signature which requires all these parameters" +)] unsafe fn handle_posix_spawn( config: ExecResolveConfig, original: PosixSpawnFn, @@ -28,11 +32,17 @@ unsafe fn handle_posix_spawn( envp: *const *mut c_char, ) -> c_int { struct AssertSend(T); + #[expect( + clippy::non_send_fields_in_send_ty, + reason = "the closure captures raw pointers that are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe" + )] + // SAFETY: the raw pointers captured inside T are valid for the duration of the thread::scope call, so sending them to the scoped thread is safe unsafe impl Send for AssertSend {} let client = global_client() .expect("posix_spawn(p) unexpectedly called before client initialized in ctor"); + // SAFETY: file, argv, and envp are valid pointers forwarded from the interposed posix_spawn(p) function let result = unsafe { client.handle_exec::( config, @@ -81,6 +91,7 @@ unsafe extern "C" fn posix_spawnp( argv: *const *mut c_char, envp: *const *mut c_char, ) -> libc::c_int { + // SAFETY: all arguments are valid pointers forwarded from the interposed posix_spawnp function unsafe { handle_posix_spawn( ExecResolveConfig::search_path_enabled(None), @@ -104,6 +115,7 @@ unsafe extern "C" fn posix_spawn( argv: *const *mut c_char, envp: *const *mut c_char, ) -> libc::c_int { + // SAFETY: all arguments are valid pointers forwarded from the interposed posix_spawn function unsafe { handle_posix_spawn( ExecResolveConfig::search_path_disabled(), diff --git a/crates/fspy_preload_unix/src/interceptions/stat.rs b/crates/fspy_preload_unix/src/interceptions/stat.rs index 5782f123..c70ff270 100644 --- a/crates/fspy_preload_unix/src/interceptions/stat.rs +++ b/crates/fspy_preload_unix/src/interceptions/stat.rs @@ -11,26 +11,32 @@ use crate::{ intercept!(stat(64): unsafe extern "C" fn(path: *const c_char, buf: *mut stat_struct) -> c_int); unsafe extern "C" fn stat(path: *const c_char, buf: *mut stat_struct) -> c_int { + // SAFETY: path is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(path, AccessMode::READ); } + // SAFETY: calling the original libc stat() with the same arguments forwarded from the interposed function unsafe { stat::original()(path, buf) } } intercept!(lstat(64): unsafe extern "C" fn(path: *const c_char, buf: *mut stat_struct) -> c_int); unsafe extern "C" fn lstat(path: *const c_char, buf: *mut stat_struct) -> c_int { // TODO: add accessmode ReadNoFollow + // SAFETY: path is a valid C string pointer provided by the caller of the interposed function unsafe { handle_open(path, AccessMode::READ); } + // SAFETY: calling the original libc lstat() with the same arguments forwarded from the interposed function unsafe { lstat::original()(path, buf) } } intercept!(fstat(64): unsafe extern "C" fn(fd: c_int, buf: *mut stat_struct) -> c_int); unsafe extern "C" fn fstat(fd: c_int, buf: *mut stat_struct) -> c_int { + // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function unsafe { handle_open(Fd(fd), AccessMode::READ); } + // SAFETY: calling the original libc fstat() with the same arguments forwarded from the interposed function unsafe { fstat::original()(fd, buf) } } @@ -41,8 +47,10 @@ unsafe extern "C" fn fstatat( buf: *mut stat_struct, flags: c_int, ) -> c_int { + // SAFETY: dirfd and pathname are valid arguments provided by the caller of the interposed function unsafe { handle_open(PathAt(dirfd, pathname), AccessMode::READ); } + // SAFETY: calling the original libc fstatat() with the same arguments forwarded from the interposed function unsafe { fstatat::original()(dirfd, pathname, buf, flags) } } diff --git a/crates/fspy_preload_unix/src/lib.rs b/crates/fspy_preload_unix/src/lib.rs index 2e5a6b5b..8c482394 100644 --- a/crates/fspy_preload_unix/src/lib.rs +++ b/crates/fspy_preload_unix/src/lib.rs @@ -1,6 +1,12 @@ #![cfg(unix)] // required for defining inteposed `open`/`openat`(https://man7.org/linux/man-pages/man2/open.2.html) #![feature(c_variadic)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] mod client; mod interceptions; diff --git a/crates/fspy_preload_unix/src/macros/linux.rs b/crates/fspy_preload_unix/src/macros/linux.rs index 1c220567..34f748c6 100644 --- a/crates/fspy_preload_unix/src/macros/linux.rs +++ b/crates/fspy_preload_unix/src/macros/linux.rs @@ -38,10 +38,11 @@ pub(crate) use intercept; #[cfg(test)] #[doc(hidden)] -pub(crate) fn symbol_exists(name: &str) -> bool { +pub fn symbol_exists(name: &str) -> bool { use std::ffi::CString; let name = CString::new(name).unwrap(); + // SAFETY: dlsym with RTLD_DEFAULT searches for the symbol in the default shared object search order !unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr().cast()) }.is_null() } @@ -62,10 +63,15 @@ macro_rules! intercept_inner { } }; mod $name { - #[allow(unused)] + #[expect(clippy::allow_attributes, reason = "using allow because unused_imports may or may not fire depending on macro expansion")] + #[allow(unused_imports, reason = "glob import brings types into scope for macro-generated code")] use super::*; pub unsafe fn original() -> $fn_sig { - static LAZY: std::sync::LazyLock<$fn_sig> = std::sync::LazyLock::new(|| unsafe { + static LAZY: std::sync::LazyLock<$fn_sig> = std::sync::LazyLock::new(|| + // SAFETY: dlsym with RTLD_NEXT returns the next symbol in the dynamic linking order, + // and transmute converts the resulting function pointer to the expected function signature. + // The caller guarantees the symbol name matches the expected function signature via the macro invocation. + unsafe { ::core::mem::transmute(::libc::dlsym( ::libc::RTLD_NEXT, ::core::concat!(::core::stringify!($name), "\0").as_ptr().cast(), diff --git a/crates/fspy_preload_unix/src/macros/macos.rs b/crates/fspy_preload_unix/src/macros/macos.rs index 5c8a2320..fd319119 100644 --- a/crates/fspy_preload_unix/src/macros/macos.rs +++ b/crates/fspy_preload_unix/src/macros/macos.rs @@ -8,14 +8,15 @@ macro_rules! intercept { const _: $fn_sig = $crate::libc::$name; #[used] - #[allow(dead_code)] #[unsafe(link_section = "__DATA,__interpose")] static mut _INTERPOSE_ENTRY: $crate::macros::InterposeEntry = $crate::macros::InterposeEntry { _new: $name as _, _old: $crate::libc::$name as _ }; }; mod $name { - #[allow(unused)] + // macro-generated: imports may or may not be used depending on expansion context + #[expect(clippy::allow_attributes, reason = "macro-generated: imports may or may not be used depending on expansion context")] + #[allow(unused_imports, reason = "macro-generated: imports may or may not be used depending on expansion context")] use super::*; pub fn original() -> $fn_sig { $crate::libc::$name diff --git a/crates/fspy_preload_unix/src/macros/mod.rs b/crates/fspy_preload_unix/src/macros/mod.rs index 19bade7d..5b3fa805 100644 --- a/crates/fspy_preload_unix/src/macros/mod.rs +++ b/crates/fspy_preload_unix/src/macros/mod.rs @@ -6,4 +6,8 @@ mod os_impl; #[path = "./linux.rs"] mod os_impl; +#[expect( + clippy::redundant_pub_crate, + reason = "macro_rules! macros cannot be `pub`, only `pub(crate)` at most" +)] pub(crate) use os_impl::*; diff --git a/crates/fspy_preload_windows/src/lib.rs b/crates/fspy_preload_windows/src/lib.rs index 04957cd8..32a9747e 100644 --- a/crates/fspy_preload_windows/src/lib.rs +++ b/crates/fspy_preload_windows/src/lib.rs @@ -1,4 +1,10 @@ #![cfg(windows)] #![feature(sync_unsafe_cell)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] pub mod windows; diff --git a/crates/fspy_preload_windows/src/windows/client.rs b/crates/fspy_preload_windows/src/windows/client.rs index 7e60e038..1c0cb174 100644 --- a/crates/fspy_preload_windows/src/windows/client.rs +++ b/crates/fspy_preload_windows/src/windows/client.rs @@ -25,7 +25,13 @@ impl<'a> Client<'a> { // this can happen if the process is started after the root target process has exited. // By that time the channel would have been closed in the receiver side. // In this case we just leave a message and skip sending any path accesses. - eprintln!("fspy: failed to create ipc sender: {}", err); + #[expect( + clippy::print_stderr, + reason = "preload library uses stderr for debug diagnostics" + )] + { + eprintln!("fspy: failed to create ipc sender: {err}"); + } None } }; @@ -42,6 +48,7 @@ impl<'a> Client<'a> { pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL { let payload_bytes = encode_to_vec(&self.payload, BINCODE_CONFIG).unwrap(); + // SAFETY: FFI call to DetourCopyPayloadToProcess with valid handle and payload buffer unsafe { DetourCopyPayloadToProcess( child_handle, @@ -52,7 +59,8 @@ impl<'a> Client<'a> { } } - pub fn ansi_dll_path(&self) -> &'a CStr { + pub const fn ansi_dll_path(&self) -> &'a CStr { + // SAFETY: payload.ansi_dll_path_with_nul is guaranteed to be a valid null-terminated byte string unsafe { CStr::from_bytes_with_nul_unchecked(self.payload.ansi_dll_path_with_nul) } } } @@ -61,9 +69,11 @@ static CLIENT: SyncUnsafeCell>> = SyncUnsafeCell::new(MaybeUninit::uninit()); pub unsafe fn set_global_client(client: Client<'static>) { + // SAFETY: called once during DLL_PROCESS_ATTACH before any concurrent access unsafe { *CLIENT.get() = MaybeUninit::new(client) } } pub unsafe fn global_client() -> &'static Client<'static> { + // SAFETY: CLIENT is initialized via set_global_client during DLL_PROCESS_ATTACH unsafe { (*CLIENT.get()).assume_init_ref() } } diff --git a/crates/fspy_preload_windows/src/windows/convert.rs b/crates/fspy_preload_windows/src/windows/convert.rs index e33a21af..72a09578 100644 --- a/crates/fspy_preload_windows/src/windows/convert.rs +++ b/crates/fspy_preload_windows/src/windows/convert.rs @@ -39,9 +39,10 @@ impl ToAbsolutePath for HANDLE { self, f: F, ) -> winsafe::SysResult { - let path = unsafe { get_path_name(self) }.ok(); - let path = path.as_ref().map(|path| U16Str::from_slice(&path)); - f(path) + // SAFETY: get_path_name performs FFI call with this HANDLE to retrieve the file path + let resolved = unsafe { get_path_name(self) }.ok(); + let resolved = resolved.as_ref().map(|p| U16Str::from_slice(p)); + f(resolved) } } @@ -50,33 +51,38 @@ impl ToAbsolutePath for POBJECT_ATTRIBUTES { self, f: F, ) -> winsafe::SysResult { - let filename_str = if let Some(object_name) = unsafe { (*self).ObjectName.as_ref() } { - unsafe { get_u16_str(object_name) } - } else { - U16Str::from_slice(&[]) - }; - let filename_slice = filename_str.as_slice(); - let is_absolute = filename_slice.get(0) == Some(&b'\\'.into()) // \... - || filename_slice.get(1) == Some(&b':'.into()); // C:... + // SAFETY: dereferencing POBJECT_ATTRIBUTES to read ObjectName field from Windows API struct + let fname_str = unsafe { (*self).ObjectName.as_ref() }.map_or_else( + || U16Str::from_slice(&[]), + |object_name| { + // SAFETY: reading UNICODE_STRING fields from a valid OBJECT_ATTRIBUTES + unsafe { get_u16_str(object_name) } + }, + ); + let fname_slice = fname_str.as_slice(); + let is_absolute = fname_slice.first() == Some(&b'\\'.into()) // \... + || fname_slice.get(1) == Some(&b':'.into()); // C:... - if !is_absolute { + if is_absolute { + f(Some(fname_str)) + } else { + // SAFETY: dereferencing POBJECT_ATTRIBUTES to read RootDirectory handle let Ok(mut root_dir) = (unsafe { get_path_name((*self).RootDirectory) }) else { return f(None); }; // If filename is empty, just use root_dir directly - if filename_str.is_empty() { + if fname_str.is_empty() { let root_dir_str = U16Str::from_slice(&root_dir); return f(Some(root_dir_str)); } let root_dir_cstr = { root_dir.push(0); + // SAFETY: we just pushed a null terminator, so the buffer is null-terminated unsafe { U16CStr::from_ptr_str(root_dir.as_ptr()) } }; - let filename_cstr = U16CString::from_ustr_truncate(filename_str); - let abs_path = combine_paths(root_dir_cstr, filename_cstr.as_ucstr()).unwrap(); + let fname_cstring = U16CString::from_ustr_truncate(fname_str); + let abs_path = combine_paths(root_dir_cstr, fname_cstring.as_ucstr()).unwrap(); f(Some(abs_path.to_u16_str())) - } else { - f(Some(filename_str)) } } } diff --git a/crates/fspy_preload_windows/src/windows/detour.rs b/crates/fspy_preload_windows/src/windows/detour.rs index 92bce9c7..d7a8c8a3 100644 --- a/crates/fspy_preload_windows/src/windows/detour.rs +++ b/crates/fspy_preload_windows/src/windows/detour.rs @@ -9,6 +9,7 @@ use winsafe::SysResult; use crate::windows::winapi_utils::ck_long; +// SAFETY: Detour is only mutated during DLL attach/detach (single-threaded DLL_PROCESS_ATTACH) unsafe impl Sync for Detour {} pub struct Detour { symbol_name: &'static CStr, @@ -18,14 +19,17 @@ pub struct Detour { impl Detour { pub const unsafe fn new(symbol_name: &'static CStr, target: T, new: T) -> Self { - Detour { symbol_name, target: UnsafeCell::new(unsafe { transmute_copy(&target) }), new } + // SAFETY: transmute_copy reinterprets the function pointer as *mut c_void for Detours API + Self { symbol_name, target: UnsafeCell::new(unsafe { transmute_copy(&target) }), new } } pub const unsafe fn dynamic(symbol_name: &'static CStr, new: T) -> Self { - Detour { symbol_name, target: UnsafeCell::new(null_mut()), new } + Self { symbol_name, target: UnsafeCell::new(null_mut()), new } } + #[must_use] pub fn real(&self) -> &T { + // SAFETY: target is initialized during Detour construction or attach; read-only after attach unsafe { &(*self.target.get().cast::()) } } @@ -34,9 +38,9 @@ impl Detour { T: Copy, { DetourAny { - symbol_name: &self.symbol_name, + symbol_name: std::ptr::addr_of!(self.symbol_name), target: self.target.get(), - new: ((&self.new) as *const T).cast(), + new: (&raw const self.new).cast(), } } } @@ -55,9 +59,13 @@ pub struct AttachContext { } impl AttachContext { + #[must_use] pub fn new() -> Self { + // SAFETY: LoadLibraryA is safe to call with valid C string pointers to system DLLs let kernelbase = unsafe { LoadLibraryA(c"kernelbase".as_ptr()) }; + // SAFETY: LoadLibraryA is safe to call with valid C string pointers to system DLLs let kernel32 = unsafe { LoadLibraryA(c"kernel32".as_ptr()) }; + // SAFETY: LoadLibraryA is safe to call with valid C string pointers to system DLLs let ntdll = unsafe { LoadLibraryA(c"ntdll".as_ptr()) }; assert_ne!(kernelbase, null_mut()); assert_ne!(kernel32, null_mut()); @@ -66,39 +74,52 @@ impl AttachContext { } } +// SAFETY: DetourAny is only used during DLL attach/detach (single-threaded DLL_PROCESS_ATTACH) unsafe impl Sync for DetourAny {} impl DetourAny { pub unsafe fn attach(&self, ctx: &AttachContext) -> SysResult<()> { + // SAFETY: dereferencing pointer to static CStr symbol name let symbol_name = unsafe { *self.symbol_name }.as_ptr(); + // SAFETY: GetProcAddress FFI call with valid module handle and symbol name let symbol_in_kernelbase = unsafe { GetProcAddress(ctx.kernelbase, symbol_name) }; - if !symbol_in_kernelbase.is_null() { - // stub symbol: https://github.com/microsoft/Detours/issues/328#issuecomment-2494147615 - unsafe { *self.target = symbol_in_kernelbase.cast() }; - } else { + if symbol_in_kernelbase.is_null() { + // SAFETY: reading target pointer to check if symbol was already resolved if unsafe { *self.target }.is_null() { // dynamic symbol - look up from kernel32 or ntdll + // SAFETY: GetProcAddress FFI call with valid module handle and symbol name let symbol_in_kernel32 = unsafe { GetProcAddress(ctx.kernel32, symbol_name) }; - if !symbol_in_kernel32.is_null() { - unsafe { *self.target = symbol_in_kernel32.cast() }; - } else { + if symbol_in_kernel32.is_null() { + // SAFETY: GetProcAddress FFI call with valid module handle and symbol name let symbol_in_ntdll = unsafe { GetProcAddress(ctx.ntdll, symbol_name) }; + // SAFETY: writing resolved symbol address to target pointer unsafe { *self.target = symbol_in_ntdll.cast() }; + } else { + // SAFETY: writing resolved symbol address to target pointer + unsafe { *self.target = symbol_in_kernel32.cast() }; } } + } else { + // stub symbol: https://github.com/microsoft/Detours/issues/328#issuecomment-2494147615 + // SAFETY: writing resolved symbol address to target pointer for Detours API + unsafe { *self.target = symbol_in_kernelbase.cast() }; } + // SAFETY: reading target pointer to check if symbol was resolved if unsafe { *self.target }.is_null() { // dynamic symbol not found, skip attaching return Ok(()); } + // SAFETY: DetourAttach FFI call with valid target and detour function pointers ck_long(unsafe { DetourAttach(self.target, *self.new) })?; Ok(()) } pub unsafe fn detach(&self) -> SysResult<()> { + // SAFETY: reading target pointer to check if symbol was resolved if unsafe { *self.target }.is_null() { // dynamic symbol not found, skip detaching return Ok(()); } + // SAFETY: DetourDetach FFI call with valid target and detour function pointers ck_long(unsafe { DetourDetach(self.target, *self.new) }) } } diff --git a/crates/fspy_preload_windows/src/windows/detours/create_process.rs b/crates/fspy_preload_windows/src/windows/detours/create_process.rs index 8215552b..6dd09e77 100644 --- a/crates/fspy_preload_windows/src/windows/detours/create_process.rs +++ b/crates/fspy_preload_windows/src/windows/detours/create_process.rs @@ -21,7 +21,7 @@ use crate::windows::{ }; thread_local! { - static IS_HOOKING_CREATE_PROCESS: std::cell::Cell = std::cell::Cell::new(false); + static IS_HOOKING_CREATE_PROCESS: std::cell::Cell = const { std::cell::Cell::new(false) }; } struct HookGuard; @@ -56,41 +56,11 @@ static DETOUR_CREATE_PROCESS_W: Detour< LPSTARTUPINFOW, LPPROCESS_INFORMATION, ) -> i32, -> = unsafe { - Detour::new(c"CreateProcessW", CreateProcessW, { - unsafe extern "system" fn new_fn( - lp_application_name: LPCWSTR, - lp_command_line: LPWSTR, - lp_process_attributes: LPSECURITY_ATTRIBUTES, - lp_thread_attributes: LPSECURITY_ATTRIBUTES, - b_inherit_handles: BOOL, - dw_creation_flags: DWORD, - lp_environment: LPVOID, - lp_current_directory: LPCWSTR, - lp_startup_info: LPSTARTUPINFOW, - lp_process_information: LPPROCESS_INFORMATION, - ) -> BOOL { - let Some(_hook_guard) = HookGuard::new() else { - // Detect re-entrance and avoid double hooking - return unsafe { - (DETOUR_CREATE_PROCESS_W.real())( - lp_application_name, - lp_command_line, - lp_process_attributes, - lp_thread_attributes, - b_inherit_handles, - dw_creation_flags, - lp_environment, - lp_current_directory, - lp_startup_info, - lp_process_information, - ) - }; - }; - - let client = unsafe { global_client() }; - - unsafe extern "system" fn create_process_with_payload_w( +> = + // SAFETY: initializing Detour with the real CreateProcessW function pointer and our replacement + unsafe { + Detour::new(c"CreateProcessW", CreateProcessW, { + unsafe extern "system" fn new_fn( lp_application_name: LPCWSTR, lp_command_line: LPWSTR, lp_process_attributes: LPSECURITY_ATTRIBUTES, @@ -102,60 +72,98 @@ static DETOUR_CREATE_PROCESS_W: Detour< lp_startup_info: LPSTARTUPINFOW, lp_process_information: LPPROCESS_INFORMATION, ) -> BOOL { - let ret = unsafe { - (DETOUR_CREATE_PROCESS_W.real())( + unsafe extern "system" fn create_process_with_payload_w( + lp_application_name: LPCWSTR, + lp_command_line: LPWSTR, + lp_process_attributes: LPSECURITY_ATTRIBUTES, + lp_thread_attributes: LPSECURITY_ATTRIBUTES, + b_inherit_handles: BOOL, + dw_creation_flags: DWORD, + lp_environment: LPVOID, + lp_current_directory: LPCWSTR, + lp_startup_info: LPSTARTUPINFOW, + lp_process_information: LPPROCESS_INFORMATION, + ) -> BOOL { + // SAFETY: calling original CreateProcessW with CREATE_SUSPENDED to inject DLL before resume + let ret = unsafe { + (DETOUR_CREATE_PROCESS_W.real())( + lp_application_name, + lp_command_line, + lp_process_attributes, + lp_thread_attributes, + b_inherit_handles, + dw_creation_flags | CREATE_SUSPENDED, + lp_environment, + lp_current_directory, + lp_startup_info, + lp_process_information, + ) + }; + if ret == 0 { + return 0; + } + + // SAFETY: copying payload to child process and dereferencing lp_process_information + let ret = unsafe { + global_client().prepare_child_process((*lp_process_information).hProcess) + }; + + if ret == 0 { + return 0; + } + if dw_creation_flags & CREATE_SUSPENDED == 0 { + // SAFETY: resuming the suspended child thread after DLL injection + let ret = unsafe { ResumeThread((*lp_process_information).hThread) }; + if ret == (-1i32).cast_unsigned() { + return 0; + } + } + ret + } + + let Some(_hook_guard) = HookGuard::new() else { + // Detect re-entrance and avoid double hooking + // SAFETY: calling original CreateProcessW with all original arguments + return unsafe { + (DETOUR_CREATE_PROCESS_W.real())( + lp_application_name, + lp_command_line, + lp_process_attributes, + lp_thread_attributes, + b_inherit_handles, + dw_creation_flags, + lp_environment, + lp_current_directory, + lp_startup_info, + lp_process_information, + ) + }; + }; + + // SAFETY: accessing the global client initialized during DLL_PROCESS_ATTACH + let client = unsafe { global_client() }; + + // SAFETY: calling DetourCreateProcessWithDllExW to create process with our DLL injected + unsafe { + DetourCreateProcessWithDllExW( lp_application_name, lp_command_line, lp_process_attributes, lp_thread_attributes, b_inherit_handles, - dw_creation_flags | CREATE_SUSPENDED, + dw_creation_flags, lp_environment, lp_current_directory, lp_startup_info, lp_process_information, + client.ansi_dll_path().as_ptr().cast(), + Some(create_process_with_payload_w), ) - }; - if ret == 0 { - return 0; } - - let ret = unsafe { - global_client().prepare_child_process((*lp_process_information).hProcess) - }; - - if ret == 0 { - return 0; - } - if dw_creation_flags & CREATE_SUSPENDED == 0 { - let ret = unsafe { ResumeThread((*lp_process_information).hThread) }; - if ret == -1i32 as DWORD { - return 0; - } - } - ret - } - - unsafe { - DetourCreateProcessWithDllExW( - lp_application_name, - lp_command_line, - lp_process_attributes, - lp_thread_attributes, - b_inherit_handles, - dw_creation_flags, - lp_environment, - lp_current_directory, - lp_startup_info, - lp_process_information, - client.ansi_dll_path().as_ptr().cast(), - Some(create_process_with_payload_w), - ) } - } - new_fn - }) -}; + new_fn + }) + }; static DETOUR_CREATE_PROCESS_A: Detour< unsafe extern "system" fn( @@ -170,40 +178,11 @@ static DETOUR_CREATE_PROCESS_A: Detour< LPSTARTUPINFOA, LPPROCESS_INFORMATION, ) -> i32, -> = unsafe { - Detour::new(c"CreateProcessA", CreateProcessA, { - unsafe extern "system" fn new_fn( - lp_application_name: LPCSTR, - lp_command_line: LPSTR, - lp_process_attributes: LPSECURITY_ATTRIBUTES, - lp_thread_attributes: LPSECURITY_ATTRIBUTES, - b_inherit_handles: BOOL, - dw_creation_flags: DWORD, - lp_environment: LPVOID, - lp_current_directory: LPCSTR, - lp_startup_info: LPSTARTUPINFOA, - lp_process_information: LPPROCESS_INFORMATION, - ) -> BOOL { - let Some(_hook_guard) = HookGuard::new() else { - // Detect re-entrance and avoid double hooking - return unsafe { - (DETOUR_CREATE_PROCESS_A.real())( - lp_application_name, - lp_command_line, - lp_process_attributes, - lp_thread_attributes, - b_inherit_handles, - dw_creation_flags, - lp_environment, - lp_current_directory, - lp_startup_info, - lp_process_information, - ) - }; - }; - let client = unsafe { global_client() }; - - unsafe extern "system" fn create_process_with_payload_a( +> = + // SAFETY: initializing Detour with the real CreateProcessA function pointer and our replacement + unsafe { + Detour::new(c"CreateProcessA", CreateProcessA, { + unsafe extern "system" fn new_fn( lp_application_name: LPCSTR, lp_command_line: LPSTR, lp_process_attributes: LPSECURITY_ATTRIBUTES, @@ -215,59 +194,96 @@ static DETOUR_CREATE_PROCESS_A: Detour< lp_startup_info: LPSTARTUPINFOA, lp_process_information: LPPROCESS_INFORMATION, ) -> BOOL { - let ret = unsafe { - (DETOUR_CREATE_PROCESS_A.real())( + unsafe extern "system" fn create_process_with_payload_a( + lp_application_name: LPCSTR, + lp_command_line: LPSTR, + lp_process_attributes: LPSECURITY_ATTRIBUTES, + lp_thread_attributes: LPSECURITY_ATTRIBUTES, + b_inherit_handles: BOOL, + dw_creation_flags: DWORD, + lp_environment: LPVOID, + lp_current_directory: LPCSTR, + lp_startup_info: LPSTARTUPINFOA, + lp_process_information: LPPROCESS_INFORMATION, + ) -> BOOL { + // SAFETY: calling original CreateProcessA with CREATE_SUSPENDED to inject DLL before resume + let ret = unsafe { + (DETOUR_CREATE_PROCESS_A.real())( + lp_application_name, + lp_command_line, + lp_process_attributes, + lp_thread_attributes, + b_inherit_handles, + dw_creation_flags | CREATE_SUSPENDED, + lp_environment, + lp_current_directory, + lp_startup_info, + lp_process_information, + ) + }; + if ret == 0 { + return 0; + } + + // SAFETY: copying payload to child process and dereferencing lp_process_information + let ret = unsafe { + global_client().prepare_child_process((*lp_process_information).hProcess) + }; + + if ret == 0 { + return 0; + } + if dw_creation_flags & CREATE_SUSPENDED == 0 { + // SAFETY: resuming the suspended child thread after DLL injection + let ret = unsafe { ResumeThread((*lp_process_information).hThread) }; + if ret == (-1i32).cast_unsigned() { + return 0; + } + } + ret + } + + let Some(_hook_guard) = HookGuard::new() else { + // Detect re-entrance and avoid double hooking + // SAFETY: calling original CreateProcessA with all original arguments + return unsafe { + (DETOUR_CREATE_PROCESS_A.real())( + lp_application_name, + lp_command_line, + lp_process_attributes, + lp_thread_attributes, + b_inherit_handles, + dw_creation_flags, + lp_environment, + lp_current_directory, + lp_startup_info, + lp_process_information, + ) + }; + }; + // SAFETY: accessing the global client initialized during DLL_PROCESS_ATTACH + let client = unsafe { global_client() }; + + // SAFETY: calling DetourCreateProcessWithDllExA to create process with our DLL injected + unsafe { + DetourCreateProcessWithDllExA( lp_application_name, lp_command_line, lp_process_attributes, lp_thread_attributes, b_inherit_handles, - dw_creation_flags | CREATE_SUSPENDED, + dw_creation_flags, lp_environment, lp_current_directory, lp_startup_info, lp_process_information, + client.ansi_dll_path().as_ptr().cast(), + Some(create_process_with_payload_a), ) - }; - if ret == 0 { - return 0; - } - - let ret = unsafe { - global_client().prepare_child_process((*lp_process_information).hProcess) - }; - - if ret == 0 { - return 0; } - if dw_creation_flags & CREATE_SUSPENDED == 0 { - let ret = unsafe { ResumeThread((*lp_process_information).hThread) }; - if ret == -1i32 as DWORD { - return 0; - } - } - ret - } - - unsafe { - DetourCreateProcessWithDllExA( - lp_application_name, - lp_command_line, - lp_process_attributes, - lp_thread_attributes, - b_inherit_handles, - dw_creation_flags, - lp_environment, - lp_current_directory, - lp_startup_info, - lp_process_information, - client.ansi_dll_path().as_ptr().cast(), - Some(create_process_with_payload_a), - ) } - } - new_fn - }) -}; + new_fn + }) + }; pub const DETOURS: &[DetourAny] = &[DETOUR_CREATE_PROCESS_W.as_any(), DETOUR_CREATE_PROCESS_A.as_any()]; diff --git a/crates/fspy_preload_windows/src/windows/detours/nt.rs b/crates/fspy_preload_windows/src/windows/detours/nt.rs index cbefa0fd..da85eb8b 100644 --- a/crates/fspy_preload_windows/src/windows/detours/nt.rs +++ b/crates/fspy_preload_windows/src/windows/detours/nt.rs @@ -35,42 +35,46 @@ static DETOUR_NT_CREATE_FILE: Detour< ea_buffer: PVOID, ea_length: ULONG, ) -> HFILE, -> = unsafe { - Detour::new(c"NtCreateFile", ntapi::ntioapi::NtCreateFile, { - unsafe extern "system" fn new_nt_create_file( - file_handle: PHANDLE, - desired_access: ACCESS_MASK, - object_attributes: POBJECT_ATTRIBUTES, - io_status_block: PIO_STATUS_BLOCK, - allocation_size: PLARGE_INTEGER, - file_attributes: ULONG, - share_access: ULONG, - create_disposition: ULONG, - create_options: ULONG, - ea_buffer: PVOID, - ea_length: ULONG, - ) -> HFILE { - unsafe { handle_open(desired_access, object_attributes) }; +> = + // SAFETY: initializing Detour with the real NtCreateFile function pointer and our replacement + unsafe { + Detour::new(c"NtCreateFile", ntapi::ntioapi::NtCreateFile, { + unsafe extern "system" fn new_nt_create_file( + file_handle: PHANDLE, + desired_access: ACCESS_MASK, + object_attributes: POBJECT_ATTRIBUTES, + io_status_block: PIO_STATUS_BLOCK, + allocation_size: PLARGE_INTEGER, + file_attributes: ULONG, + share_access: ULONG, + create_disposition: ULONG, + create_options: ULONG, + ea_buffer: PVOID, + ea_length: ULONG, + ) -> HFILE { + // SAFETY: intercepting file open to record access before forwarding to real function + unsafe { handle_open(desired_access, object_attributes) }; - unsafe { - (DETOUR_NT_CREATE_FILE.real())( - file_handle, - desired_access, - object_attributes, - io_status_block, - allocation_size, - file_attributes, - share_access, - create_disposition, - create_options, - ea_buffer, - ea_length, - ) + // SAFETY: calling the original NtCreateFile with all original arguments + unsafe { + (DETOUR_NT_CREATE_FILE.real())( + file_handle, + desired_access, + object_attributes, + io_status_block, + allocation_size, + file_attributes, + share_access, + create_disposition, + create_options, + ea_buffer, + ea_length, + ) + } } - } - new_nt_create_file - }) -}; + new_nt_create_file + }) + }; static DETOUR_NT_OPEN_FILE: Detour< unsafe extern "system" fn( @@ -81,76 +85,93 @@ static DETOUR_NT_OPEN_FILE: Detour< share_access: ULONG, open_options: ULONG, ) -> HFILE, -> = unsafe { - Detour::new(c"NtOpenFile", ntapi::ntioapi::NtOpenFile, { - unsafe extern "system" fn new_nt_open_file( - file_handle: PHANDLE, - desired_access: ACCESS_MASK, - object_attributes: POBJECT_ATTRIBUTES, - io_status_block: PIO_STATUS_BLOCK, - share_access: ULONG, - open_options: ULONG, - ) -> HFILE { - unsafe { - handle_open(desired_access, object_attributes); - } +> = + // SAFETY: initializing Detour with the real NtOpenFile function pointer and our replacement + unsafe { + Detour::new(c"NtOpenFile", ntapi::ntioapi::NtOpenFile, { + unsafe extern "system" fn new_nt_open_file( + file_handle: PHANDLE, + desired_access: ACCESS_MASK, + object_attributes: POBJECT_ATTRIBUTES, + io_status_block: PIO_STATUS_BLOCK, + share_access: ULONG, + open_options: ULONG, + ) -> HFILE { + // SAFETY: intercepting file open to record access before forwarding to real function + unsafe { + handle_open(desired_access, object_attributes); + } - unsafe { - (DETOUR_NT_OPEN_FILE.real())( - file_handle, - desired_access, - object_attributes, - io_status_block, - share_access, - open_options, - ) + // SAFETY: calling the original NtOpenFile with all original arguments + unsafe { + (DETOUR_NT_OPEN_FILE.real())( + file_handle, + desired_access, + object_attributes, + io_status_block, + share_access, + open_options, + ) + } } - } - new_nt_open_file - }) -}; + new_nt_open_file + }) + }; static DETOUR_NT_QUERY_ATTRIBUTES_FILE: Detour< unsafe extern "system" fn( object_attributes: POBJECT_ATTRIBUTES, file_information: PFILE_BASIC_INFORMATION, ) -> HFILE, -> = unsafe { - Detour::new(c"NtQueryAttributesFile", ntapi::ntioapi::NtQueryAttributesFile, { - unsafe extern "system" fn new_nt_open_file( - object_attributes: POBJECT_ATTRIBUTES, - file_information: PFILE_BASIC_INFORMATION, - ) -> HFILE { - unsafe { handle_open(AccessMode::READ, object_attributes) }; - unsafe { (DETOUR_NT_QUERY_ATTRIBUTES_FILE.real())(object_attributes, file_information) } - } - new_nt_open_file - }) -}; +> = + // SAFETY: initializing Detour with the real NtQueryAttributesFile function pointer and our replacement + unsafe { + Detour::new(c"NtQueryAttributesFile", ntapi::ntioapi::NtQueryAttributesFile, { + unsafe extern "system" fn new_nt_query_attrs( + object_attributes: POBJECT_ATTRIBUTES, + file_information: PFILE_BASIC_INFORMATION, + ) -> HFILE { + // SAFETY: intercepting attribute query to record read access + unsafe { handle_open(AccessMode::READ, object_attributes) }; + // SAFETY: calling the original NtQueryAttributesFile with all original arguments + unsafe { + (DETOUR_NT_QUERY_ATTRIBUTES_FILE.real())(object_attributes, file_information) + } + } + new_nt_query_attrs + }) + }; unsafe fn handle_open(access_mode: impl ToAccessMode, path: impl ToAbsolutePath) { + // SAFETY: accessing the global client which was initialized during DLL_PROCESS_ATTACH let client = unsafe { global_client() }; + // SAFETY: resolving path from Windows object attributes or handle for access tracking unsafe { path.to_absolute_path(|path| { let Some(path) = path else { return Ok(()); }; let path = path.as_slice(); - let path_access = if let Some(wildcard_pos) = - path.iter().rposition(|c| *c == b'*' as u16) - { - let path_before_wildcard = &path[..wildcard_pos]; - let slash_pos = path_before_wildcard - .iter() - .rposition(|c| *c == b'\\' as u16 || *c == b'/' as u16) - .unwrap_or(0); - PathAccess { - mode: AccessMode::READ_DIR, - path: NativeStr::from_wide(&path[..slash_pos]), - } - } else { - PathAccess { mode: access_mode.to_access_mode(), path: NativeStr::from_wide(path) } - }; + 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: NativeStr::from_wide(path), + } + }, + |wildcard_pos| { + let path_before_wildcard = &path[..wildcard_pos]; + let slash_pos = path_before_wildcard + .iter() + .rposition(|c| *c == u16::from(b'\\') || *c == u16::from(b'/')) + .unwrap_or(0); + PathAccess { + mode: AccessMode::READ_DIR, + path: NativeStr::from_wide(&path[..slash_pos]), + } + }, + ); client.send(path_access); Ok(()) }) @@ -163,20 +184,27 @@ static DETOUR_NT_FULL_QUERY_ATTRIBUTES_FILE: Detour< object_attributes: POBJECT_ATTRIBUTES, file_information: PFILE_NETWORK_OPEN_INFORMATION, ) -> HFILE, -> = unsafe { - Detour::new(c"NtQueryFullAttributesFile", NtQueryFullAttributesFile, { - unsafe extern "system" fn new_fn( - object_attributes: POBJECT_ATTRIBUTES, - file_information: PFILE_NETWORK_OPEN_INFORMATION, - ) -> HFILE { - unsafe { handle_open(GENERIC_READ, object_attributes) }; - unsafe { - (DETOUR_NT_FULL_QUERY_ATTRIBUTES_FILE.real())(object_attributes, file_information) +> = + // SAFETY: initializing Detour with the real NtQueryFullAttributesFile function pointer + unsafe { + Detour::new(c"NtQueryFullAttributesFile", NtQueryFullAttributesFile, { + unsafe extern "system" fn new_fn( + object_attributes: POBJECT_ATTRIBUTES, + file_information: PFILE_NETWORK_OPEN_INFORMATION, + ) -> HFILE { + // SAFETY: intercepting attribute query to record read access + unsafe { handle_open(GENERIC_READ, object_attributes) }; + // SAFETY: calling the original NtQueryFullAttributesFile + unsafe { + (DETOUR_NT_FULL_QUERY_ATTRIBUTES_FILE.real())( + object_attributes, + file_information, + ) + } } - } - new_fn - }) -}; + new_fn + }) + }; static DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT: Detour< unsafe extern "system" fn( @@ -184,25 +212,29 @@ static DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT: Detour< desired_access: ACCESS_MASK, object_attributes: POBJECT_ATTRIBUTES, ) -> HFILE, -> = unsafe { - Detour::new(c"NtOpenSymbolicLinkObject", ntapi::ntobapi::NtOpenSymbolicLinkObject, { - unsafe extern "system" fn new_fn( - link_handle: PHANDLE, - desired_access: ACCESS_MASK, - object_attributes: POBJECT_ATTRIBUTES, - ) -> HFILE { - unsafe { handle_open(desired_access, object_attributes) }; - unsafe { - (DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.real())( - link_handle, - desired_access, - object_attributes, - ) +> = + // SAFETY: initializing Detour with the real NtOpenSymbolicLinkObject function pointer + unsafe { + Detour::new(c"NtOpenSymbolicLinkObject", ntapi::ntobapi::NtOpenSymbolicLinkObject, { + unsafe extern "system" fn new_fn( + link_handle: PHANDLE, + desired_access: ACCESS_MASK, + object_attributes: POBJECT_ATTRIBUTES, + ) -> HFILE { + // SAFETY: intercepting symlink open to record access + unsafe { handle_open(desired_access, object_attributes) }; + // SAFETY: calling the original NtOpenSymbolicLinkObject + unsafe { + (DETOUR_NT_OPEN_SYMBOLIC_LINK_OBJECT.real())( + link_handle, + desired_access, + object_attributes, + ) + } } - } - new_fn - }) -}; + new_fn + }) + }; static DETOUR_NT_QUERY_INFORMATION_BY_NAME: Detour< unsafe extern "system" fn( @@ -212,29 +244,33 @@ static DETOUR_NT_QUERY_INFORMATION_BY_NAME: Detour< length: ULONG, file_information_class: FILE_INFORMATION_CLASS, ) -> HFILE, -> = unsafe { - Detour::new(c"NtQueryInformationByName", NtQueryInformationByName, { - unsafe extern "system" fn new_fn( - object_attributes: POBJECT_ATTRIBUTES, - io_status_block: PIO_STATUS_BLOCK, - file_information: PVOID, - length: ULONG, - file_information_class: FILE_INFORMATION_CLASS, - ) -> HFILE { - unsafe { handle_open(GENERIC_READ, object_attributes) }; - unsafe { - (DETOUR_NT_QUERY_INFORMATION_BY_NAME.real())( - object_attributes, - io_status_block, - file_information, - length, - file_information_class, - ) +> = + // SAFETY: initializing Detour with the real NtQueryInformationByName function pointer + unsafe { + Detour::new(c"NtQueryInformationByName", NtQueryInformationByName, { + unsafe extern "system" fn new_fn( + object_attributes: POBJECT_ATTRIBUTES, + io_status_block: PIO_STATUS_BLOCK, + file_information: PVOID, + length: ULONG, + file_information_class: FILE_INFORMATION_CLASS, + ) -> HFILE { + // SAFETY: intercepting information query to record read access + unsafe { handle_open(GENERIC_READ, object_attributes) }; + // SAFETY: calling the original NtQueryInformationByName + unsafe { + (DETOUR_NT_QUERY_INFORMATION_BY_NAME.real())( + object_attributes, + io_status_block, + file_information, + length, + file_information_class, + ) + } } - } - new_fn - }) -}; + new_fn + }) + }; static DETOUR_NT_QUERY_DIRECTORY_FILE: Detour< unsafe extern "system" fn( @@ -250,41 +286,45 @@ static DETOUR_NT_QUERY_DIRECTORY_FILE: Detour< file_name: PUNICODE_STRING, restart_scan: BOOLEAN, ) -> NTSTATUS, -> = unsafe { - Detour::new(c"NtQueryDirectoryFile", NtQueryDirectoryFile, { - unsafe extern "system" fn new_fn( - file_handle: HANDLE, - event: HANDLE, - apc_routine: PIO_APC_ROUTINE, - apc_context: PVOID, - io_status_block: PIO_STATUS_BLOCK, - file_information: PVOID, - length: ULONG, - file_information_class: FILE_INFORMATION_CLASS, - return_single_entry: BOOLEAN, - file_name: PUNICODE_STRING, - restart_scan: BOOLEAN, - ) -> NTSTATUS { - unsafe { handle_open(AccessMode::READ_DIR, file_handle) }; - unsafe { - (DETOUR_NT_QUERY_DIRECTORY_FILE.real())( - file_handle, - event, - apc_routine, - apc_context, - io_status_block, - file_information, - length, - file_information_class, - return_single_entry, - file_name, - restart_scan, - ) +> = + // SAFETY: initializing Detour with the real NtQueryDirectoryFile function pointer + unsafe { + Detour::new(c"NtQueryDirectoryFile", NtQueryDirectoryFile, { + unsafe extern "system" fn new_fn( + file_handle: HANDLE, + event: HANDLE, + apc_routine: PIO_APC_ROUTINE, + apc_context: PVOID, + io_status_block: PIO_STATUS_BLOCK, + file_information: PVOID, + length: ULONG, + file_information_class: FILE_INFORMATION_CLASS, + return_single_entry: BOOLEAN, + file_name: PUNICODE_STRING, + restart_scan: BOOLEAN, + ) -> NTSTATUS { + // SAFETY: intercepting directory query to record directory read access + unsafe { handle_open(AccessMode::READ_DIR, file_handle) }; + // SAFETY: calling the original NtQueryDirectoryFile + unsafe { + (DETOUR_NT_QUERY_DIRECTORY_FILE.real())( + file_handle, + event, + apc_routine, + apc_context, + io_status_block, + file_information, + length, + file_information_class, + return_single_entry, + file_name, + restart_scan, + ) + } } - } - new_fn - }) -}; + new_fn + }) + }; // NtQueryDirectoryFileEx is not in ntapi crate, so we define it here. // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntquerydirectoryfileex @@ -301,39 +341,43 @@ type NtQueryDirectoryFileExFn = unsafe extern "system" fn( file_name: PUNICODE_STRING, ) -> NTSTATUS; -static DETOUR_NT_QUERY_DIRECTORY_FILE_EX: Detour = unsafe { - Detour::dynamic(c"NtQueryDirectoryFileEx", { - unsafe extern "system" fn new_fn( - file_handle: HANDLE, - event: HANDLE, - apc_routine: PIO_APC_ROUTINE, - apc_context: PVOID, - io_status_block: PIO_STATUS_BLOCK, - file_information: PVOID, - length: ULONG, - file_information_class: FILE_INFORMATION_CLASS, - query_flags: ULONG, - file_name: PUNICODE_STRING, - ) -> NTSTATUS { - unsafe { handle_open(AccessMode::READ_DIR, file_handle) }; - unsafe { - (DETOUR_NT_QUERY_DIRECTORY_FILE_EX.real())( - file_handle, - event, - apc_routine, - apc_context, - io_status_block, - file_information, - length, - file_information_class, - query_flags, - file_name, - ) +static DETOUR_NT_QUERY_DIRECTORY_FILE_EX: Detour = + // SAFETY: initializing dynamic Detour for NtQueryDirectoryFileEx (resolved at attach time) + unsafe { + Detour::dynamic(c"NtQueryDirectoryFileEx", { + unsafe extern "system" fn new_fn( + file_handle: HANDLE, + event: HANDLE, + apc_routine: PIO_APC_ROUTINE, + apc_context: PVOID, + io_status_block: PIO_STATUS_BLOCK, + file_information: PVOID, + length: ULONG, + file_information_class: FILE_INFORMATION_CLASS, + query_flags: ULONG, + file_name: PUNICODE_STRING, + ) -> NTSTATUS { + // SAFETY: intercepting directory query to record directory read access + unsafe { handle_open(AccessMode::READ_DIR, file_handle) }; + // SAFETY: calling the original NtQueryDirectoryFileEx + unsafe { + (DETOUR_NT_QUERY_DIRECTORY_FILE_EX.real())( + file_handle, + event, + apc_routine, + apc_context, + io_status_block, + file_information, + length, + file_information_class, + query_flags, + file_name, + ) + } } - } - new_fn - }) -}; + new_fn + }) + }; pub const DETOURS: &[DetourAny] = &[ DETOUR_NT_CREATE_FILE.as_any(), diff --git a/crates/fspy_preload_windows/src/windows/mod.rs b/crates/fspy_preload_windows/src/windows/mod.rs index 97045d25..0d1612a3 100644 --- a/crates/fspy_preload_windows/src/windows/mod.rs +++ b/crates/fspy_preload_windows/src/windows/mod.rs @@ -26,6 +26,7 @@ use winsafe::SetLastError; use crate::windows::detour::AttachContext; fn dll_main(_hinstance: HINSTANCE, reason: u32) -> winsafe::SysResult<()> { + // SAFETY: FFI call to check if this is a Detours helper process if unsafe { DetourIsHelperProcess() } == TRUE { return Ok(()); } @@ -33,36 +34,48 @@ fn dll_main(_hinstance: HINSTANCE, reason: u32) -> winsafe::SysResult<()> { match reason { winnt::DLL_PROCESS_ATTACH => { // dbg!((current_exe(), std::process::id())); + // SAFETY: FFI call to restore Detours state after DLL injection ck(unsafe { DetourRestoreAfterWith() })?; let mut payload_len: DWORD = 0; + // SAFETY: FFI call to find the injected payload by GUID let payload_ptr = - unsafe { DetourFindPayloadEx(&PAYLOAD_ID, &mut payload_len).cast::() }; + unsafe { DetourFindPayloadEx(&PAYLOAD_ID, &raw mut payload_len).cast::() }; + // SAFETY: creating a static slice from the payload pointer; lifetime is valid for process duration let payload_bytes = unsafe { slice::from_raw_parts::<'static, u8>(payload_ptr, payload_len.try_into().unwrap()) }; let client = Client::from_payload_bytes(payload_bytes); + // SAFETY: setting the global client during single-threaded DLL_PROCESS_ATTACH unsafe { set_global_client(client) }; let ctx = AttachContext::new(); + // SAFETY: FFI call to begin a Detours transaction ck_long(unsafe { DetourTransactionBegin() })?; + // SAFETY: FFI call to update the current thread in the Detours transaction ck_long(unsafe { DetourUpdateThread(GetCurrentThread().cast()) })?; for d in DETOURS { + // SAFETY: attaching each detour within the active Detours transaction unsafe { d.attach(&ctx) }?; } + // SAFETY: FFI call to commit the Detours transaction ck_long(unsafe { DetourTransactionCommit() })?; } winnt::DLL_PROCESS_DETACH => { + // SAFETY: FFI call to begin a Detours transaction for detaching ck(unsafe { DetourTransactionBegin() })?; + // SAFETY: FFI call to update the current thread in the Detours transaction ck(unsafe { DetourUpdateThread(GetCurrentThread().cast()) })?; for d in DETOURS { + // SAFETY: detaching each detour within the active Detours transaction unsafe { d.detach() }?; } + // SAFETY: FFI call to commit the Detours transaction ck(unsafe { DetourTransactionCommit() })?; } _ => {} @@ -71,7 +84,6 @@ fn dll_main(_hinstance: HINSTANCE, reason: u32) -> winsafe::SysResult<()> { } #[unsafe(no_mangle)] -#[allow(non_snake_case, unused_variables)] extern "system" fn DllMain(hinstance: HINSTANCE, reason: u32, _: *mut std::ffi::c_void) -> BOOL { match dll_main(hinstance, reason) { Ok(()) => TRUE, diff --git a/crates/fspy_preload_windows/src/windows/winapi_utils.rs b/crates/fspy_preload_windows/src/windows/winapi_utils.rs index afd0ba06..578efbda 100644 --- a/crates/fspy_preload_windows/src/windows/winapi_utils.rs +++ b/crates/fspy_preload_windows/src/windows/winapi_utils.rs @@ -24,8 +24,13 @@ pub fn ck(b: BOOL) -> winsafe::SysResult<()> { if b == FALSE { Err(GetLastError()) } else { Ok(()) } } -pub fn ck_long(val: c_long) -> winsafe::SysResult<()> { - if 0 == NO_ERROR { Ok(()) } else { Err(unsafe { winsafe::co::ERROR::from_raw(val as _) }) } +pub const fn ck_long(val: c_long) -> winsafe::SysResult<()> { + if 0 == NO_ERROR { + Ok(()) + } else { + // SAFETY: creating an ERROR from the raw c_long value for the Windows error code + Err(unsafe { winsafe::co::ERROR::from_raw(val.cast_unsigned()) }) + } } pub unsafe fn get_u16_str(ustring: &UNICODE_STRING) -> &U16Str { @@ -37,16 +42,15 @@ pub unsafe fn get_u16_str(ustring: &UNICODE_STRING) -> &U16Str { // Buffer may be null in that case. &[] } else { - unsafe { slice::from_raw_parts((*ustring).Buffer, u16_count.try_into().unwrap()) } + // SAFETY: UNICODE_STRING.Buffer points to a valid u16 array of Length/2 elements + unsafe { slice::from_raw_parts(ustring.Buffer, usize::from(u16_count)) } }; - match U16CStr::from_slice_truncate(chars) { - Ok(ok) => ok.as_ustr(), - Err(_) => chars.into(), - } + U16CStr::from_slice_truncate(chars).map_or_else(|_| chars.into(), U16CStr::as_ustr) } pub unsafe fn get_path_name(handle: HANDLE) -> winsafe::SysResult> { let mut path = SmallVec::::new(); + // SAFETY: FFI call to GetFinalPathNameByHandleW to query the file path from a handle let len = unsafe { GetFinalPathNameByHandleW( handle, @@ -60,9 +64,11 @@ pub unsafe fn get_path_name(handle: HANDLE) -> winsafe::SysResult winsafe::SysResult path.capacity() { unreachable!() } + // SAFETY: GetFinalPathNameByHandleW wrote `len` u16 characters into the buffer unsafe { path.set_len(len) }; } Ok(path) } -pub fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { +pub const fn access_mask_to_mode(desired_access: ACCESS_MASK) -> AccessMode { let has_write = (desired_access & (FILE_WRITE_DATA | FILE_APPEND_DATA | GENERIC_WRITE)) != 0; let has_read = (desired_access & (FILE_READ_DATA | GENERIC_READ)) != 0; if has_write { - if has_read { AccessMode::READ | AccessMode::WRITE } else { AccessMode::WRITE } + if has_read { AccessMode::READ.union(AccessMode::WRITE) } else { AccessMode::WRITE } } else { AccessMode::READ } @@ -104,28 +111,33 @@ unsafe extern "system" { pub struct HeapPath(PWSTR); impl HeapPath { + #[must_use] pub fn to_u16_str(&self) -> &U16Str { + // SAFETY: the PWSTR was allocated by PathAllocCombine and is a valid null-terminated wide string unsafe { U16CStr::from_ptr_str(self.0).as_ustr() } } } impl Drop for HeapPath { fn drop(&mut self) { + // SAFETY: freeing the PWSTR allocated by PathAllocCombine via LocalFree unsafe { LocalFree(self.0.cast()) }; } } pub fn combine_paths(path1: &U16CStr, path2: &U16CStr) -> winsafe::SysResult { - const PATHCCH_ALLOW_LONG_PATHS: ULONG = 0x00000001; + const PATHCCH_ALLOW_LONG_PATHS: ULONG = 0x0000_0001; let mut out = std::ptr::null_mut(); + // SAFETY: FFI call to PathAllocCombine with valid null-terminated wide string pointers let hr = unsafe { PathAllocCombine( path1.as_ptr(), path2.as_ptr(), PATHCCH_ALLOW_LONG_PATHS, /*PATHCOMBINE_DEFAULT*/ - &mut out, + &raw mut out, ) }; if hr != S_OK { + // SAFETY: creating an ERROR from the HRESULT value return Err(unsafe { co::ERROR::from_raw(hr.try_into().unwrap()) }); } Ok(HeapPath(out)) @@ -146,6 +158,7 @@ mod tests { let tmpdir = tempfile::tempdir().unwrap(); let path = tmpdir.path().canonicalize().unwrap().join(filename); let file = File::create(&path).unwrap(); + // SAFETY: passing a valid raw file handle to get_path_name let actual_path = unsafe { get_path_name(file.as_raw_handle().cast()) }.unwrap(); let actual_path = PathBuf::from(OsString::from_wide(&actual_path)); assert_eq!(path, actual_path); @@ -153,11 +166,11 @@ mod tests { #[test] fn test_get_path_name_short() { - test_get_path_name("foo") + test_get_path_name("foo"); } #[test] fn test_get_path_name_long() { - test_get_path_name(str::repeat("a", 255).as_str()) + test_get_path_name(str::repeat("a", 255).as_str()); } #[test] diff --git a/crates/fspy_seccomp_unotify/src/bindings/alloc.rs b/crates/fspy_seccomp_unotify/src/bindings/alloc.rs index 068c4534..024287de 100644 --- a/crates/fspy_seccomp_unotify/src/bindings/alloc.rs +++ b/crates/fspy_seccomp_unotify/src/bindings/alloc.rs @@ -38,15 +38,25 @@ pub struct Alloced { } impl Alloced { + /// Allocates a zero-initialized buffer with the given layout. + /// + /// # Safety + /// The `layout` must have a size large enough to hold a value of type `T` and + /// must have proper alignment for `T`. pub(crate) unsafe fn alloc(layout: Layout) -> Self { + // SAFETY: layout is non-zero-sized (guaranteed by caller) and properly aligned let ptr = unsafe { alloc::alloc_zeroed(layout) }; let ptr = NonNull::new(ptr).unwrap(); Self { ptr: ptr.cast(), layout } } - pub(crate) fn zeroed(&mut self) -> &mut T { + pub(crate) const fn zeroed(&mut self) -> &mut T { + // SAFETY: `self.ptr` was allocated with `self.layout.size()` bytes, + // so writing that many zero bytes is within bounds unsafe { self.ptr.cast::().write_bytes(0, self.layout.size()) }; + // SAFETY: the pointer is valid, properly aligned, and the buffer has just + // been zero-initialized, which is valid for the kernel structs used here unsafe { self.ptr.as_mut() } } } @@ -55,25 +65,42 @@ impl Deref for Alloced { type Target = T; fn deref(&self) -> &Self::Target { + // SAFETY: the pointer is valid and properly aligned, allocated in `alloc()` unsafe { self.ptr.as_ref() } } } impl Drop for Alloced { fn drop(&mut self) { + // SAFETY: `self.ptr` was allocated with `alloc::alloc_zeroed` using `self.layout`, + // so it is safe to deallocate with the same layout unsafe { alloc::dealloc(self.ptr.as_ptr().cast(), self.layout); } } } +// SAFETY: `Alloced` owns a heap allocation and does not use thread-local storage. +// It is safe to send across threads when `T` itself is `Send + Sync`. unsafe impl Send for Alloced {} +// SAFETY: `Alloced` only provides shared access via `Deref`, which is safe +// when `T` is `Send + Sync`. unsafe impl Sync for Alloced {} +/// Allocates a zero-initialized buffer for a `seccomp_notif` struct, sized to at least +/// what the kernel requires. +#[must_use] pub fn alloc_seccomp_notif() -> Alloced { + // SAFETY: `BUF_SIZES.req_layout` is computed from `get_notif_sizes()` and + // `size_of::()`, guaranteeing sufficient size and alignment unsafe { Alloced::alloc(BUF_SIZES.req_layout) } } +/// Allocates a zero-initialized buffer for a `seccomp_notif_resp` struct, sized to at least +/// what the kernel requires. +#[must_use] pub fn alloc_seccomp_notif_resp() -> Alloced { + // SAFETY: `BUF_SIZES.resp_layout` is computed from `get_notif_sizes()` and + // `size_of::()`, guaranteeing sufficient size and alignment unsafe { Alloced::alloc(BUF_SIZES.resp_layout) } } diff --git a/crates/fspy_seccomp_unotify/src/bindings/mod.rs b/crates/fspy_seccomp_unotify/src/bindings/mod.rs index 1fe94427..cd8e7b2e 100644 --- a/crates/fspy_seccomp_unotify/src/bindings/mod.rs +++ b/crates/fspy_seccomp_unotify/src/bindings/mod.rs @@ -1,21 +1,21 @@ +#[cfg(feature = "supervisor")] pub mod alloc; +#[cfg(feature = "supervisor")] use alloc::Alloced; -use std::{ - mem::zeroed, - os::{ - fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd}, - raw::c_int, - }, -}; +use std::os::raw::c_int; -use libc::{SECCOMP_GET_NOTIF_SIZES, seccomp_notif, syscall}; +use libc::syscall; +/// # Safety +/// The `args` pointer must be valid for the given `operation`, or null if the operation +/// does not require arguments. unsafe fn seccomp( operation: libc::c_uint, flags: libc::c_uint, args: *mut libc::c_void, ) -> nix::Result { + // SAFETY: caller guarantees `args` is valid for the given seccomp operation let ret = unsafe { syscall(libc::SYS_seccomp, operation, flags, args) }; if ret < 0 { return Err(nix::Error::last()); @@ -23,14 +23,31 @@ unsafe fn seccomp( Ok(c_int::try_from(ret).unwrap()) } +#[cfg(feature = "supervisor")] fn get_notif_sizes() -> nix::Result { + use std::mem::zeroed; + // SAFETY: `seccomp_notif_sizes` is a plain data struct safe to zero-initialize let mut sizes = unsafe { zeroed::() }; - unsafe { seccomp(SECCOMP_GET_NOTIF_SIZES, 0, (&raw mut sizes).cast()) }?; + // SAFETY: `sizes` is a valid mutable pointer to a `seccomp_notif_sizes` struct, + // which is the expected argument for `SECCOMP_GET_NOTIF_SIZES` + unsafe { seccomp(libc::SECCOMP_GET_NOTIF_SIZES, 0, (&raw mut sizes).cast()) }?; Ok(sizes) } -pub fn notif_recv(fd: BorrowedFd<'_>, notif_buf: &mut Alloced) -> nix::Result<()> { - const SECCOMP_IOCTL_NOTIF_RECV: libc::c_ulong = 3226476800; +/// Receives a seccomp notification from the given file descriptor into the provided buffer. +/// +/// # Errors +/// Returns an error if the ioctl call fails (e.g., the fd is invalid or the kernel +/// returns an error). +#[cfg(feature = "supervisor")] +pub fn notif_recv( + fd: std::os::fd::BorrowedFd<'_>, + notif_buf: &mut Alloced, +) -> nix::Result<()> { + use std::os::fd::AsRawFd; + const SECCOMP_IOCTL_NOTIF_RECV: libc::c_ulong = 3_226_476_800; + // 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 { libc::ioctl(fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_RECV, (&raw mut *notif_buf.zeroed())) }; @@ -40,12 +57,22 @@ pub fn notif_recv(fd: BorrowedFd<'_>, notif_buf: &mut Alloced) -> Ok(()) } -pub fn install_unotify_filter(prog: &[libc::sock_filter]) -> nix::Result { +/// Installs a seccomp user notification filter and returns the notification file descriptor. +/// +/// # Errors +/// Returns an error if the seccomp syscall fails (e.g., invalid filter program or +/// insufficient privileges). +#[cfg(feature = "target")] +pub fn install_unotify_filter(prog: &[libc::sock_filter]) -> nix::Result { + use std::os::fd::FromRawFd; let mut filter = libc::sock_fprog { len: prog.len().try_into().unwrap(), filter: prog.as_ptr().cast_mut().cast(), }; + // SAFETY: `filter` is a valid `sock_fprog` pointing to the BPF program slice, + // and `SECCOMP_FILTER_FLAG_NEW_LISTENER` requests a notification fd + #[expect(clippy::cast_possible_truncation, reason = "flag value fits in u32")] let fd = unsafe { seccomp( libc::SECCOMP_SET_MODE_FILTER, @@ -54,5 +81,7 @@ pub fn install_unotify_filter(prog: &[libc::sock_filter]) -> nix::Result for CodableSockFilter { impl From for libc::sock_filter { fn from(filter: CodableSockFilter) -> Self { let CodableSockFilter { code, jt, jf, k } = filter; - libc::sock_filter { code, jt, jf, k } + Self { code, jt, jf, k } } } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs b/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs index f4431f02..61fd353d 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs +++ b/crates/fspy_seccomp_unotify/src/supervisor/handler/arg.rs @@ -10,6 +10,10 @@ use libc::{pid_t, seccomp_notif}; use nix::sys::uio::{RemoteIoVec, process_vm_readv}; pub trait FromSyscallArg: Sized { + /// Converts a raw syscall argument into this type. + /// + /// # Errors + /// Returns an error if the argument value cannot be interpreted as this type. fn from_syscall_arg(arg: u64) -> io::Result; } /// Represents the caller of a syscall. Needed to read memory from the caller's address space. @@ -26,7 +30,8 @@ impl<'a> Caller<'a> { f(Self { pid, _marker: std::marker::PhantomData }) } - pub fn read_vm(self, starting_addr: usize) -> ProcessVmReader<'a> { + #[must_use] + pub const fn read_vm(self, starting_addr: usize) -> ProcessVmReader<'a> { ProcessVmReader { caller: self, current_addr: starting_addr } } } @@ -36,7 +41,7 @@ pub struct ProcessVmReader<'a> { current_addr: usize, } -impl<'a> io::Read for ProcessVmReader<'a> { +impl io::Read for ProcessVmReader<'_> { fn read(&mut self, buf: &mut [u8]) -> io::Result { let buf_len = buf.len(); let read_len = process_vm_readv( @@ -44,9 +49,10 @@ impl<'a> io::Read for ProcessVmReader<'a> { &mut [IoSliceMut::new(buf)], &[RemoteIoVec { base: self.current_addr, len: buf_len }], )?; - self.current_addr = self.current_addr.checked_add(read_len).ok_or_else(|| { - io::Error::new(io::ErrorKind::Other, "address overflow while reading remote process") - })?; + self.current_addr = self + .current_addr + .checked_add(read_len) + .ok_or_else(|| io::Error::other("address overflow while reading remote process"))?; Ok(read_len) } } @@ -63,7 +69,10 @@ impl CStrPtr { /// - `Ok(None)` if the buffer was filled without encountering a null-terminator. /// - `Err(UnexpectedEof)` if Eof was reached without encountering a null-terminator. /// - `Err(other_err)` on other errors from reading the remote process memory. - pub fn read(&self, caller: Caller<'_>, buf: &mut [u8]) -> io::Result> { + /// + /// # Errors + /// Returns an error if reading from the remote process memory fails. + pub fn read(self, caller: Caller<'_>, buf: &mut [u8]) -> io::Result> { let mut reader = caller.read_vm(self.remote_ptr); let mut pos = 0; while let Some((_, unfilled)) = buf.split_at_mut_checked(pos) { @@ -87,8 +96,9 @@ impl CStrPtr { } impl FromSyscallArg for CStrPtr { + #[expect(clippy::cast_possible_truncation, reason = "syscall arg represents a pointer address")] fn from_syscall_arg(arg: u64) -> io::Result { - Ok(Self { remote_ptr: arg as _ }) + Ok(Self { remote_ptr: arg as usize }) } } @@ -98,20 +108,28 @@ pub struct Ptr { } impl FromSyscallArg for Ptr { fn from_syscall_arg(arg: u64) -> io::Result { - Ok(Self { remote_ptr: arg as _, _marker: PhantomData }) + Ok(Self { remote_ptr: arg as *mut c_void, _marker: PhantomData }) } } impl Ptr { /// Reads the value of type T from the remote process memory. - /// # Safety: + /// + /// # Safety /// The remote pointer must be valid and point to a value of type T in the remote process memory. + /// + /// # Errors + /// Returns an error if reading from the remote process memory fails. pub unsafe fn read(&self, caller: Caller<'_>) -> io::Result { let mut reader = caller.read_vm(self.remote_ptr as usize); let mut buf = MaybeUninit::::zeroed(); + // SAFETY: `MaybeUninit` has the same layout as `T`, so casting to a + // byte slice of `size_of::()` bytes is valid for writing into let buf_slice = unsafe { - std::slice::from_raw_parts_mut(buf.as_mut_ptr() as *mut u8, std::mem::size_of::()) + std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast::(), std::mem::size_of::()) }; reader.read_exact(buf_slice)?; + // SAFETY: all bytes of `buf` have been initialized by `read_exact`, + // and the caller guarantees the remote pointer points to a valid `T` Ok(unsafe { buf.assume_init() }) } } @@ -120,7 +138,7 @@ impl Ptr { pub struct Ignored(()); impl FromSyscallArg for Ignored { fn from_syscall_arg(_arg: u64) -> io::Result { - Ok(Ignored(())) + Ok(Self(())) } } @@ -130,20 +148,26 @@ pub struct Fd { } impl Fd { - pub fn cwd() -> Self { + #[must_use] + pub const fn cwd() -> Self { Self { fd: libc::AT_FDCWD } } } impl FromSyscallArg for Fd { + #[expect(clippy::cast_possible_truncation, reason = "syscall arg represents a file descriptor")] fn from_syscall_arg(arg: u64) -> io::Result { - Ok(Self { fd: arg as _ }) + Ok(Self { fd: arg as RawFd }) } } impl Fd { // TODO: allocate in arena - pub fn get_path(&self, caller: Caller<'_>) -> nix::Result { + /// Returns the filesystem path associated with this file descriptor. + /// + /// # Errors + /// Returns an error if the `/proc` readlink fails (e.g., the process has exited). + pub fn get_path(self, caller: Caller<'_>) -> nix::Result { nix::fcntl::readlink( if self.fd == libc::AT_FDCWD { format!("/proc/{}/cwd", caller.pid) @@ -156,12 +180,17 @@ impl Fd { } impl FromSyscallArg for c_int { + #[expect(clippy::cast_possible_truncation, reason = "syscall arg represents a c_int value")] fn from_syscall_arg(arg: u64) -> io::Result { - Ok(arg as _) + Ok(arg as Self) } } pub trait FromNotify: Sized { + /// Parses syscall arguments from a seccomp notification. + /// + /// # Errors + /// Returns an error if any argument cannot be parsed. fn from_notify(notif: &seccomp_notif) -> io::Result; } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs b/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs index ca19d066..52151c29 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs +++ b/crates/fspy_seccomp_unotify/src/supervisor/handler/mod.rs @@ -4,8 +4,13 @@ use std::io; use libc::seccomp_notif; +#[expect(clippy::module_name_repetitions, reason = "clearer as a standalone export")] pub trait SeccompNotifyHandler { fn syscalls() -> &'static [syscalls::Sysno]; + /// Handles a seccomp notification for an intercepted syscall. + /// + /// # Errors + /// Returns an error if the handler fails to process the notification. fn handle_notify(&mut self, notify: &seccomp_notif) -> io::Result<()>; } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/listener.rs b/crates/fspy_seccomp_unotify/src/supervisor/listener.rs index d8a0a352..165949f2 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/listener.rs +++ b/crates/fspy_seccomp_unotify/src/supervisor/listener.rs @@ -1,6 +1,5 @@ use std::{ io, - ops::Deref, os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}, }; @@ -31,9 +30,14 @@ impl AsFd for NotifyListener { } } -const SECCOMP_IOCTL_NOTIF_SEND: libc::c_ulong = 3222806785; +const SECCOMP_IOCTL_NOTIF_SEND: libc::c_ulong = 3_222_806_785; impl NotifyListener { + /// Sends a `SECCOMP_USER_NOTIF_FLAG_CONTINUE` response for the given request ID. + /// + /// # Errors + /// Returns an error if the ioctl call fails, except for `ENOENT` which is + /// silently ignored (indicates the target process's syscall was interrupted). pub fn send_continue( &self, req_id: u64, @@ -41,8 +45,13 @@ impl NotifyListener { ) -> io::Result<()> { let resp = buf.zeroed(); resp.id = req_id; - resp.flags = libc::SECCOMP_USER_NOTIF_FLAG_CONTINUE as _; + #[expect(clippy::cast_possible_truncation, reason = "flag constant fits in u32")] + { + resp.flags = libc::SECCOMP_USER_NOTIF_FLAG_CONTINUE as u32; + } + // SAFETY: `resp` is a valid mutable pointer to a zeroed and populated + // `seccomp_notif_resp` buffer, and the fd is a valid seccomp notify fd let ret = unsafe { libc::ioctl(self.async_fd.as_raw_fd(), SECCOMP_IOCTL_NOTIF_SEND, &raw mut *resp) }; @@ -51,12 +60,16 @@ impl NotifyListener { // ignore error if target process's syscall was interrupted if err == nix::Error::ENOENT { return Ok(()); - }; + } return Err(err.into()); - }; + } Ok(()) } + /// Waits for and returns the next seccomp notification, or `None` if the fd is closed. + /// + /// # Errors + /// Returns an error if waiting on or reading from the notification fd fails. pub async fn next(&mut self) -> io::Result> { loop { let mut ready_guard = self.async_fd.readable().await?; @@ -73,8 +86,8 @@ impl NotifyListener { ready_guard.clear_ready(); match notif_recv(ready_guard.get_inner().as_fd(), &mut self.notif_buf) { - Ok(()) => return Ok(Some(self.notif_buf.deref())), - Err(nix::Error::EINTR | nix::Error::EWOULDBLOCK | nix::Error::ENOENT) => continue, + Ok(()) => return Ok(Some(&self.notif_buf)), + Err(nix::Error::EINTR | nix::Error::EWOULDBLOCK | nix::Error::ENOENT) => {} Err(other_error) => return Err(other_error.into()), } } diff --git a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs b/crates/fspy_seccomp_unotify/src/supervisor/mod.rs index 846f2221..f6e9c7e7 100644 --- a/crates/fspy_seccomp_unotify/src/supervisor/mod.rs +++ b/crates/fspy_seccomp_unotify/src/supervisor/mod.rs @@ -37,23 +37,38 @@ pub struct Supervisor { } impl Supervisor { - pub fn payload(&self) -> &SeccompPayload { + #[must_use] + pub const fn payload(&self) -> &SeccompPayload { &self.payload } + /// Stops the supervisor and returns all handler instances. + /// + /// # Panics + /// Panics if the handling loop task has panicked. + /// + /// # Errors + /// Returns an error if any of the spawned handler tasks failed with an I/O error. pub async fn stop(self) -> io::Result> { drop(self.cancel_tx); self.handling_loop_task.await.expect("handling loop task panicked") } } +/// Creates a new supervisor that listens for seccomp user notifications. +/// +/// # Panics +/// Panics if the seccomp filter cannot be compiled or the target architecture is unsupported. +/// +/// # Errors +/// Returns an error if the temporary IPC socket cannot be created. pub fn supervise() -> io::Result> { let notify_listener = tempfile::Builder::new() .prefix("fspy_seccomp_notify") .make(|path| UnixListener::bind(path))?; - let filter = SeccompFilter::new( + let seccomp_filter = SeccompFilter::new( H::syscalls().iter().map(|sysno| (sysno.id().into(), vec![])).collect(), SeccompAction::Allow, SeccompAction::Raw(libc::SECCOMP_RET_USER_NOTIF), @@ -61,16 +76,13 @@ pub fn supervise() -> io::Re ) .unwrap(); - let filter = Filter( - BpfProgram::try_from(filter) - .unwrap() - .into_iter() - .map(|sock_filter| sock_filter.into()) - .collect(), - ); + let bpf_filter = + Filter(BpfProgram::try_from(seccomp_filter).unwrap().into_iter().map(Into::into).collect()); - let payload = - SeccompPayload { ipc_path: notify_listener.path().as_os_str().as_bytes().to_vec(), filter }; + let payload = SeccompPayload { + ipc_path: notify_listener.path().as_os_str().as_bytes().to_vec(), + filter: bpf_filter, + }; // The oneshot channel is used to cancel the accept loop. // The sender doesn't need to actually send anything. Drop is enough. @@ -87,6 +99,8 @@ pub fn supervise() -> io::Re Either::Right((incoming, _)) => incoming?, }; let notify_fd = incoming_stream.recv_fd().await?; + // SAFETY: `recv_fd` returns a valid file descriptor received via + // Unix domain socket fd passing let notify_fd = unsafe { OwnedFd::from_raw_fd(notify_fd) }; let mut listener = NotifyListener::try_from(notify_fd)?; @@ -99,8 +113,8 @@ pub fn supervise() -> io::Re // Errors on the supervisor side could be caused by a target process aborting. // It shouldn't break the syscall handling loop as there might be target processes. let _handle_result = handler.handle_notify(notify); - let notify_id = notify.id; - listener.send_continue(notify_id, &mut resp_buf)?; + let req_id = notify.id; + listener.send_continue(req_id, &mut resp_buf)?; } io::Result::Ok(handler) }); diff --git a/crates/fspy_seccomp_unotify/src/target.rs b/crates/fspy_seccomp_unotify/src/target.rs index e70cfe09..5406f796 100644 --- a/crates/fspy_seccomp_unotify/src/target.rs +++ b/crates/fspy_seccomp_unotify/src/target.rs @@ -12,6 +12,12 @@ use passfd::FdPassingExt; use crate::{bindings::install_unotify_filter, payload::SeccompPayload}; +/// Installs the seccomp user notification filter and sends the notification fd +/// to the supervisor via the IPC socket. +/// +/// # Errors +/// Returns an error if setting no-new-privs fails, the filter cannot be installed, +/// or the IPC socket communication fails. pub fn install_target(payload: &SeccompPayload) -> nix::Result<()> { set_no_new_privs()?; let sock_filters = diff --git a/crates/fspy_seccomp_unotify/tests/arg_types.rs b/crates/fspy_seccomp_unotify/tests/arg_types.rs index 8d75f2ba..42ec2a74 100644 --- a/crates/fspy_seccomp_unotify/tests/arg_types.rs +++ b/crates/fspy_seccomp_unotify/tests/arg_types.rs @@ -1,4 +1,10 @@ #![cfg(target_os = "linux")] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "test file for non-vite crate" +)] use std::{ env::{current_dir, set_current_dir}, @@ -36,12 +42,10 @@ struct SyscallRecorder(Vec); impl SyscallRecorder { fn openat(&mut self, caller: Caller<'_>, (fd, path): (Fd, CStrPtr)) -> io::Result<()> { let at_dir = fd.get_path(caller)?; - let mut buf = [0u8; 40000]; - let path = if let Some(null_pos) = path.read(caller, &mut buf)? { - Some(OsString::from_vec(buf[..null_pos].to_vec())) - } else { - None - }; + let mut buf = vec![0u8; 40000]; + let path = path + .read(caller, &mut buf)? + .map(|null_pos| OsString::from_vec(buf[..null_pos].to_vec())); self.0.push(Syscall::Openat { at_dir, path }); Ok(()) } @@ -58,6 +62,9 @@ async fn run_in_pre_exec( let payload = supervisor.payload().clone(); + // SAFETY: `pre_exec` closure runs in the forked child process before exec. + // It installs the seccomp filter and runs the user-provided closure, both of + // which are safe in a pre-exec context (no async, no locks held). unsafe { cmd.pre_exec(move || { install_target(&payload)?; @@ -80,7 +87,7 @@ async fn run_in_pre_exec( let recorders = supervisor.stop().await?; trace!("{} recorders awaited", recorders.len()); - let syscalls = recorders.into_iter().map(|recorder| recorder.0.into_iter()).flatten(); + let syscalls = recorders.into_iter().flat_map(|recorder| recorder.0); io::Result::Ok(syscalls.collect()) }) .await??) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index b6dfc085..41ef9dc4 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -22,11 +22,18 @@ pub struct ChannelConf { } /// Creates a mpsc IPC channel with one receiver and a `ChannelConf` that can be passed around processes and used to create multiple senders +#[expect( + clippy::missing_errors_doc, + reason = "non-vite crate: cannot use vite_str/vite_path types" +)] pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { // Initialize the lock file with a unique name. let lock_file_path = temp_dir().join(format!("fspy_ipc_{}.lock", Uuid::new_v4())); - #[allow(unused_mut)] + #[cfg_attr( + not(windows), + expect(unused_mut, reason = "mut required on Windows, unused on Unix") + )] let mut conf = ShmemConf::new().size(capacity); // On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios #[cfg(target_os = "windows")] @@ -50,11 +57,18 @@ impl ChannelConf { /// Creates a sender. /// /// This doesn't block on the file lock. Instead it returns immediately with error if the receiver is locked or dropped. + #[expect( + clippy::missing_errors_doc, + reason = "error conditions are self-evident from return type" + )] pub fn sender(&self) -> io::Result { let lock_file = File::open(self.lock_file_path.to_cow_os_str())?; lock_file.try_lock_shared()?; - #[allow(unused_mut)] + #[cfg_attr( + not(windows), + expect(unused_mut, reason = "mut required on Windows, unused on Unix") + )] let mut conf = ShmemConf::new().size(self.shm_size).os_id(&self.shm_id); // On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios #[cfg(target_os = "windows")] @@ -62,6 +76,8 @@ impl ChannelConf { conf = conf.allow_raw(true); } let shm = conf.open().map_err(io::Error::other)?; + // SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size. + // Exclusive write access is ensured by the shared file lock held by this sender. let writer = unsafe { ShmWriter::new(shm) }; Ok(Sender { writer, lock_file, lock_file_path: self.lock_file_path.clone() }) } @@ -89,10 +105,14 @@ impl Deref for Sender { } } -/// Safety: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +#[expect( + clippy::non_send_fields_in_send_ty, + reason = "`Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to" +)] +/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. unsafe impl Send for Sender {} -/// Safety: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. +/// SAFETY: `Sender` holds a shared file lock that ensures there's no reader, so `shm` can be safely written to. unsafe impl Sync for Sender {} /// The unique receiver side of an IPC channel. @@ -103,10 +123,14 @@ pub struct Receiver { shm: Shmem, } -/// Safety: Receiver doesn't read or write `shm`. It only pass it to ReceiverLockGuard under the lock. +#[expect( + clippy::non_send_fields_in_send_ty, + reason = "Receiver doesn't read or write `shm`. It only pass it to `ReceiverLockGuard` under the lock" +)] +/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. unsafe impl Send for Receiver {} -/// Safety: Receiver doesn't read or write `shm`. It only pass it to ReceiverLockGuard under the lock. +/// SAFETY: `Receiver` doesn't read or write `shm`. It only passes it to `ReceiverLockGuard` under the lock. unsafe impl Sync for Receiver {} impl Drop for Receiver { @@ -125,9 +149,15 @@ impl Receiver { /// Lock the shared memory for unique read access. /// Blocks until all the senders have dropped (or processes owning them have all exited) so the shared memory can be safely read. - /// During the lifetime of returned `ReceiverReadGuard`, no new senders can be created (ChannelConf::sender would fail). + /// During the lifetime of returned `ReceiverReadGuard`, no new senders can be created (`ChannelConf::sender` would fail). + #[expect( + clippy::missing_errors_doc, + reason = "error conditions are self-evident from return type" + )] pub fn lock(&self) -> io::Result> { self.lock_file.lock()?; + // SAFETY: The exclusive file lock is held, so no writers can access the shared memory. + // The lock ensures all prior writes are visible to this thread. let reader = ShmReader::new(unsafe { self.shm.as_slice() }); Ok(ReceiverLockGuard { reader, lock_file: &self.lock_file }) } @@ -138,7 +168,7 @@ pub struct ReceiverLockGuard<'a> { lock_file: &'a File, } -impl<'a> Drop for ReceiverLockGuard<'a> { +impl Drop for ReceiverLockGuard<'_> { fn drop(&mut self) { if let Err(err) = self.lock_file.unlock() { debug!("Failed to unlock IPC lock file: {}", err); @@ -183,6 +213,7 @@ mod tests { } #[test] + #[expect(clippy::print_stdout, reason = "test diagnostics")] fn forbid_new_senders_after_locked() { let (conf, receiver) = channel(42).unwrap(); let _lock = receiver.lock().unwrap(); @@ -195,6 +226,7 @@ mod tests { } #[test] + #[expect(clippy::print_stdout, reason = "test diagnostics")] fn forbid_new_senders_after_receiver_dropped() { let (conf, receiver) = channel(42).unwrap(); drop(receiver); diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 70764b23..f47872ca 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -68,7 +68,7 @@ fn assert_alignment(ptr: *const u8) { assert_eq!((ptr as usize + size_of::()) % align_of::(), 0); } -fn roundup_to_align_frame_header(mut size: usize) -> usize { +const fn roundup_to_align_frame_header(mut size: usize) -> usize { // round up new_end so that the next frame header is aligned const FRAME_HEADER_ALIGN: usize = align_of::(); if !size.is_multiple_of(FRAME_HEADER_ALIGN) { @@ -139,17 +139,17 @@ impl ShmWriter { } #[cfg(test)] - fn set_fail_on_claim(&mut self, fail_on_claim: bool) { + const fn set_fail_on_claim(&mut self, fail_on_claim: bool) { self.fail_on_claim = fail_on_claim; } /// Claim a frame of size `frame_size`. /// /// Returns `None` if there is no sufficient remaining space (or simulated crash in tests) - /// frame_size must be non-zero because frame header being 0 would be ambiguous. + /// `frame_size` must be non-zero because frame header being 0 would be ambiguous. pub fn claim_frame(&self, frame_size: NonZeroUsize) -> Option> { let shm_slice: *mut [u8] = self.mem.as_raw_slice(); - let shm_ptr = shm_slice as *mut u8; + let shm_ptr = shm_slice.cast::(); let shm_len = self.mem.as_raw_slice().len(); let frame_size = frame_size.get(); @@ -162,6 +162,9 @@ impl ShmWriter { }; // Get the atomic value of the end position (first 8 bytes of shared memory) + // SAFETY: `shm_ptr` points to the start of the shared memory region, which is properly + // aligned to `usize` (verified by `assert_alignment` in `new`), and the allocation is + // large enough to contain at least a `usize` header. let atomic_header = unsafe { AtomicUsize::from_ptr(shm_ptr.cast()) }; let frame_with_header_size = size_of::() + frame_size; @@ -192,10 +195,14 @@ impl ShmWriter { // Successfully claimed the space, now write the data + // SAFETY: The atomic fetch_update above guaranteed that `size_of::() + current_end` + // is within the shared memory bounds, so this pointer arithmetic stays within the allocation. let frame_start = unsafe { shm_ptr.add(/* shm header */ size_of::() + current_end) }; + // SAFETY: `frame_start` is properly aligned to `i32` (ensured by `roundup_to_align_frame_header`) + // and points within the shared memory allocation (bounds checked by the atomic fetch_update). let frame_header = unsafe { AtomicI32::from_ptr(frame_start.cast()) }; // Mark as partially written with positive size @@ -206,9 +213,14 @@ impl ShmWriter { // Prevents compiler from re-ordering memory operations. Ensure the size is visible before writing the data fence(Ordering::Release); + // SAFETY: `frame_start` is within bounds and adding `size_of::()` skips the frame + // header to reach the content area, which is still within the claimed space. let frame_content_ptr = unsafe { frame_start.add(size_of::()) }; // skip the frame header Some(FrameMut { header: frame_header, + // SAFETY: `frame_content_ptr` is valid for `frame_size` bytes (guaranteed by the + // atomic space claim), properly aligned for `u8`, and no other writer will access + // this region because each writer atomically claims a unique range. content: unsafe { std::slice::from_raw_parts_mut(frame_content_ptr, frame_size) }, }) } @@ -257,7 +269,7 @@ impl> ShmReader { /// The content of `mem` should be created by `ShmWriter`. /// Failing to do so may result in panics (mostly out-of-bounds), but won't trigger undefined behavior. /// - /// The ShmReader must be created after all writing to the shared memory is done and visible to the calling thread. + /// The `ShmReader` must be created after all writing to the shared memory is done and visible to the calling thread. /// This is guaranteed by `M: AsRef<[u8]>`, which means the memory region is immutable during the lifetime of `ShmReader`, /// so no need to mark `ShmReader::new` as unsafe, but care must be taken to create a safe `M` from the shared memory. pub fn new(mem: M) -> Self { @@ -285,14 +297,12 @@ impl> ShmReader { 0 => { // frame was claimed but never written (crashed process) // Keep reading until we find a non-zero header - continue; } 1.. => { // Partially written frame - skip it and continue let size = usize::try_from(frame_header).unwrap(); remaining_content = &remaining_content[roundup_to_align_frame_header(size)..]; - continue; } ..0 => { // Fully written frame (negative size indicates completion) @@ -339,10 +349,15 @@ mod tests { mem: Arc>, /// The actual requested byte length. /// - /// over-allocation might happen to ensure alignment of `usize`, so mem.len() might be inaccurate. + /// over-allocation might happen to ensure alignment of `usize`, so `mem.len()` might be inaccurate. len: usize, } + // SAFETY: `MockedShm` uses `Arc>` for its backing memory, which is safe to send + // across threads. The raw pointer access through `AsRawSlice` is synchronized by `ShmWriter`'s + // atomic operations. unsafe impl Send for MockedShm {} + // SAFETY: Concurrent access to the shared memory is synchronized by `ShmWriter`'s atomic + // operations. The `Arc` wrapper ensures the allocation remains valid. unsafe impl Sync for MockedShm {} impl MockedShm { fn alloc(len: usize) -> Self { @@ -356,6 +371,9 @@ mod tests { } impl AsRef<[u8]> for MockedShm { fn as_ref(&self) -> &[u8] { + // SAFETY: `Vec::as_ptr` returns a valid pointer to the vec's buffer. The vec is + // allocated with enough `usize` elements to cover `self.len` bytes, and the pointer + // is valid for reads of `self.len` bytes. The `Arc` ensures the allocation is alive. unsafe { std::slice::from_raw_parts(Vec::as_ptr(&self.mem).cast(), self.len) } } } @@ -368,6 +386,7 @@ mod tests { #[test] fn single_thread_basic() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; assert!(writer.try_write_frame(b"hello")); assert!(writer.try_write_frame(b"world")); @@ -383,6 +402,7 @@ mod tests { } #[test] fn single_thread_empty() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; assert!(writer.try_write_frame(b"hello")); assert!(!writer.try_write_frame(b"")); @@ -397,6 +417,7 @@ mod tests { #[test] fn single_thread_crash_after_claim() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; assert!(writer.try_write_frame(b"foo")); @@ -416,6 +437,7 @@ mod tests { #[test] fn single_thread_crash_partial_write() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; assert!(writer.try_write_frame(b"foo")); @@ -440,6 +462,7 @@ mod tests { // that the reader doesn't stop at the first invalid frame but keeps processing // through multiple crash scenarios to find valid frames beyond them. + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; assert!(writer.try_write_frame(b"foo")); @@ -468,6 +491,7 @@ mod tests { #[test] fn single_thread_two_crashes_partial_write_and_after_claim() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let mut writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; // This test verifies the same loop continuation behavior but with crashes // in reverse order. This ensures the loop correctly handles different @@ -503,6 +527,9 @@ mod tests { thread::scope(|s| { for _ in 0..4 { s.spawn(|| { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized + // allocation. The clone shares the same backing memory, which is safe because + // `ShmWriter` uses atomic operations for concurrent access. let writer = unsafe { ShmWriter::new(shm.clone()) }; for _ in 0..10 { assert!(writer.try_write_frame(b"hello")); @@ -524,6 +551,7 @@ mod tests { #[test] fn concurrent_exceeded_size() { + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; thread::scope(|s| { for _ in 0..4 { @@ -550,6 +578,7 @@ mod tests { fn test_integer_overflow_space_calculation() { // Test case for potential integer overflow in space calculation + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(1024)) }; // Try to trigger integer overflow by using maximum values @@ -572,6 +601,7 @@ mod tests { // Test for race condition in space calculation where multiple threads // might calculate overlapping space requirements + // SAFETY: `MockedShm::alloc` provides a valid, properly-sized, zero-initialized allocation. let writer = unsafe { ShmWriter::new(MockedShm::alloc(200)) }; // Very small buffer @@ -605,6 +635,8 @@ mod tests { fn as_raw_slice(&self) -> *mut [u8] { let raw_slice = self.0.as_raw_slice(); slice_from_raw_parts_mut( + // SAFETY: Adding 1 byte to create a deliberately misaligned pointer for testing. + // The original allocation is large enough that adding 1 byte stays within bounds. unsafe { raw_slice.cast::().add(1) }, raw_slice.len() - 1, ) @@ -624,6 +656,8 @@ mod tests { // This should panic due to alignment assertion let result = std::panic::catch_unwind(|| { + // SAFETY: Intentionally passing a misaligned pointer to test that the alignment + // assertion in `ShmWriter::new` correctly panics. This is expected to panic. unsafe { ShmWriter::new(misaligned_shm) }; }); @@ -651,9 +685,12 @@ mod tests { 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 { - assert!(writer.try_write_frame(format!("{child_index} {i}").as_bytes())); + let frame_data = format!("{child_index} {i}"); + assert!(writer.try_write_frame(frame_data.as_bytes())); } std::process::exit(0); } @@ -677,13 +714,16 @@ mod tests { assert!(status.success()); } + // SAFETY: All child processes have exited (waited above), so no concurrent writers exist. + // The shared memory is valid and fully written. let shm = unsafe { shm.as_slice() }; let reader = ShmReader::new(shm); let frames = reader.iter_frames().map(BStr::new).collect::>(); assert_eq!(frames.len(), CHILD_COUNT * FRAME_COUNT_EACH_CHILD); for child_index in 0..CHILD_COUNT { for i in 0..FRAME_COUNT_EACH_CHILD { - assert!(frames.contains(&BStr::new(format!("{child_index} {i}").as_bytes()))); + let frame_data = format!("{child_index} {i}"); + assert!(frames.contains(&BStr::new(frame_data.as_bytes()))); } } } diff --git a/crates/fspy_shared/src/ipc/native_str.rs b/crates/fspy_shared/src/ipc/native_str.rs index 905d02f5..66f03811 100644 --- a/crates/fspy_shared/src/ipc/native_str.rs +++ b/crates/fspy_shared/src/ipc/native_str.rs @@ -26,7 +26,7 @@ use bytemuck::{TransparentWrapper, TransparentWrapperAlloc}; /// Similar to `OsStr`, but /// - Can be infallibly and losslessly encoded/decoded using bincode. -/// (`Encode`/`Decoded` implementations for `OsStr` requires it to be valid UTF-8. This does not.) +/// (`Encode`/`Decoded` implementations for `OsStr` requires it to be valid UTF-8. This does not.) /// - Can be constructed from wide characters on Windows with zero copy. /// - Supports zero-copy `BorrowDecode`. #[derive(TransparentWrapper, Encode, PartialEq, Eq)] @@ -46,6 +46,7 @@ impl NativeStr { } #[cfg(windows)] + #[must_use] pub fn from_wide(wide: &[u16]) -> &Self { Self::wrap_ref(must_cast_slice(wide)) } @@ -61,12 +62,13 @@ impl NativeStr { pub fn to_os_string(&self) -> OsString { use bytemuck::{allocation::pod_collect_to_vec, try_cast_slice}; - if let Ok(wide) = try_cast_slice::(&self.data) { - OsString::from_wide(wide) - } else { - let wide = pod_collect_to_vec::(&self.data); - OsString::from_wide(&wide) - } + try_cast_slice::(&self.data).map_or_else( + |_| { + let wide = pod_collect_to_vec::(&self.data); + OsString::from_wide(&wide) + }, + OsString::from_wide, + ) } #[must_use] @@ -129,7 +131,7 @@ impl> From for Box { } impl NativeStr { - pub fn clone_in<'new_alloc, A>(&self, alloc: &'new_alloc A) -> &'new_alloc NativeStr + pub fn clone_in<'new_alloc, A>(&self, alloc: &'new_alloc A) -> &'new_alloc Self where &'new_alloc A: Allocator, { @@ -137,7 +139,7 @@ impl NativeStr { let mut data = Vec::::with_capacity_in(self.data.len(), alloc); data.extend_from_slice(&self.data); let data = data.leak::<'new_alloc>(); - NativeStr::wrap_ref(data) + Self::wrap_ref(data) } pub fn strip_path_prefix, R, F: FnOnce(Result<&Path, StripPrefixError>) -> R>( @@ -152,6 +154,13 @@ impl NativeStr { /// \??\ is used in Nt* calls. /// The resulting path is not necessarily valid or points to the same location, /// but it's good enough for sanitizing paths in `NativeStr::strip_path_prefix`. + #[cfg_attr( + not(windows), + expect( + clippy::missing_const_for_fn, + reason = "uses non-const for loop and strip_prefix on Windows" + ) + )] fn strip_windows_path_prefix(p: &OsStr) -> &OsStr { #[cfg(windows)] { diff --git a/crates/fspy_shared/src/lib.rs b/crates/fspy_shared/src/lib.rs index e44d2b7e..c5736eab 100644 --- a/crates/fspy_shared/src/lib.rs +++ b/crates/fspy_shared/src/lib.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] + pub mod ipc; #[cfg(windows)] diff --git a/crates/fspy_shared/src/windows/mod.rs b/crates/fspy_shared/src/windows/mod.rs index ac3598a2..b92bf697 100644 --- a/crates/fspy_shared/src/windows/mod.rs +++ b/crates/fspy_shared/src/windows/mod.rs @@ -5,7 +5,20 @@ use crate::ipc::channel::ChannelConf; // Generated by guidgen.exe // {FC4845F1-3A8B-4F05-A3D3-A5E9E102AF33} -DEFINE_GUID!(PAYLOAD_ID, 0xfc4845f1, 0x3a8b, 0x4f05, 0xa3, 0xd3, 0xa5, 0xe9, 0xe1, 0x2, 0xaf, 0x33); +DEFINE_GUID!( + PAYLOAD_ID, + 0xfc48_45f1, + 0x3a8b, + 0x4f05, + 0xa3, + 0xd3, + 0xa5, + 0xe9, + 0xe1, + 0x02, + 0xaf, + 0x33 +); #[derive(Encode, BorrowDecode, Debug, Clone)] pub struct Payload<'a> { diff --git a/crates/fspy_shared_unix/src/elf.rs b/crates/fspy_shared_unix/src/elf.rs index 9dfc4ddd..a9845ea3 100644 --- a/crates/fspy_shared_unix/src/elf.rs +++ b/crates/fspy_shared_unix/src/elf.rs @@ -7,6 +7,11 @@ use std::{ use bstr::BStr; use elf::{ElfBytes, abi::PT_INTERP, endian::AnyEndian}; +/// Checks whether the given ELF executable is dynamically linked to libc. +/// +/// # Errors +/// +/// Returns `ENOEXEC` if the binary cannot be parsed as a valid ELF file. pub fn is_dynamically_linked_to_libc(executable: impl AsRef<[u8]>) -> nix::Result { let executable = executable.as_ref(); let Some(interp) = get_interp(executable)? else { @@ -20,8 +25,8 @@ pub fn is_dynamically_linked_to_libc(executable: impl AsRef<[u8]>) -> nix::Resul } fn get_interp(executable: &[u8]) -> nix::Result> { - let elf = ElfBytes::<'_, AnyEndian>::minimal_parse(executable.as_ref()) - .map_err(|_| nix::Error::ENOEXEC)?; + let elf = + ElfBytes::<'_, AnyEndian>::minimal_parse(executable).map_err(|_| nix::Error::ENOEXEC)?; let Some(headers) = elf.segments() else { return Ok(None); }; @@ -44,16 +49,15 @@ mod tests { use super::*; #[test] fn dynamic_executable() { - assert_eq!(is_dynamically_linked_to_libc(read("/bin/sh").unwrap()).unwrap(), true); + assert!(is_dynamically_linked_to_libc(read("/bin/sh").unwrap()).unwrap()); } #[test] fn static_executable() { let cat = read("/bin/cat").unwrap(); let ld_so_path = get_interp(&cat).unwrap().unwrap(); - assert_eq!( - is_dynamically_linked_to_libc(read(OsStr::from_bytes(ld_so_path)).unwrap()).unwrap(), - false + assert!( + !is_dynamically_linked_to_libc(read(OsStr::from_bytes(ld_so_path)).unwrap()).unwrap() ); } } diff --git a/crates/fspy_shared_unix/src/exec/mod.rs b/crates/fspy_shared_unix/src/exec/mod.rs index 341e610b..bfe8e3ea 100644 --- a/crates/fspy_shared_unix/src/exec/mod.rs +++ b/crates/fspy_shared_unix/src/exec/mod.rs @@ -60,8 +60,15 @@ pub struct Exec { } fn getenv(name: &CStr) -> Option<&'static CStr> { + // SAFETY: `getenv` is a C standard library function, called with a valid pointer from `CStr::as_ptr`. let value = unsafe { nix::libc::getenv(name.as_ptr().cast()) }; - if value.is_null() { None } else { Some(unsafe { CStr::from_ptr(value) }) } + if value.is_null() { + None + } else { + // SAFETY: `value` is non-null (checked above) and points to a null-terminated string owned + // by the environment, as guaranteed by the C `getenv` contract. + Some(unsafe { CStr::from_ptr(value) }) + } } fn peek_executable(path: &Path, buf: &mut [u8]) -> nix::Result { diff --git a/crates/fspy_shared_unix/src/exec/shebang.rs b/crates/fspy_shared_unix/src/exec/shebang.rs index 0d2cfeb9..245b19b4 100644 --- a/crates/fspy_shared_unix/src/exec/shebang.rs +++ b/crates/fspy_shared_unix/src/exec/shebang.rs @@ -17,6 +17,13 @@ pub struct ParseShebangOptions { pub split_arguments: bool, // TODO: recursive } +#[cfg_attr( + not(target_vendor = "apple"), + expect( + clippy::derivable_impls, + reason = "on macOS split_arguments defaults to true via cfg!, which is not derivable" + ) +)] impl Default for ParseShebangOptions { fn default() -> Self { Self { split_arguments: cfg!(target_vendor = "apple") } diff --git a/crates/fspy_shared_unix/src/exec/which.rs b/crates/fspy_shared_unix/src/exec/which.rs index a605678e..ed6e290b 100644 --- a/crates/fspy_shared_unix/src/exec/which.rs +++ b/crates/fspy_shared_unix/src/exec/which.rs @@ -11,12 +11,17 @@ fn concat(s: &[&BStr], callback: impl FnOnce(&BStr) -> R) -> R { let bytes: &[u8] = s.as_ref(); let src_ptr = bytes.as_ptr(); let dst_ptr = buf[pos..next_pos].as_mut_ptr().cast::(); + // SAFETY: `src_ptr` and `dst_ptr` are derived from valid slices of known lengths, + // they do not overlap (src is from the input slice, dst is from the stack-allocated buffer), + // and `s.len()` bytes are within bounds for both. unsafe { std::ptr::copy_nonoverlapping(src_ptr, dst_ptr, s.len()); } pos = next_pos; } debug_assert_eq!(pos, buf.len()); + // SAFETY: `buf.as_ptr()` points to a valid allocation of `buf.len()` bytes that was + // fully initialized by the copy loop above (verified by the debug_assert). let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr().cast::(), buf.len()) }; callback(bytes.as_bstr()) }) diff --git a/crates/fspy_shared_unix/src/lib.rs b/crates/fspy_shared_unix/src/lib.rs index 5437a3ef..acc91bc9 100644 --- a/crates/fspy_shared_unix/src/lib.rs +++ b/crates/fspy_shared_unix/src/lib.rs @@ -1,4 +1,10 @@ #![cfg(unix)] +#![allow( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "non-vite crate" +)] pub mod exec; pub(crate) mod open_exec; diff --git a/crates/fspy_shared_unix/src/payload.rs b/crates/fspy_shared_unix/src/payload.rs index 45190c9a..d7369d0d 100644 --- a/crates/fspy_shared_unix/src/payload.rs +++ b/crates/fspy_shared_unix/src/payload.rs @@ -15,6 +15,7 @@ pub struct Payload { pub artifacts: Artifacts, #[cfg(target_os = "linux")] + #[expect(clippy::struct_field_names, reason = "descriptive field name for clarity")] pub seccomp_payload: fspy_seccomp_unotify::payload::SeccompPayload, } diff --git a/crates/fspy_shared_unix/src/spawn/linux/mod.rs b/crates/fspy_shared_unix/src/spawn/linux/mod.rs index 570fdcb4..4d3faec7 100644 --- a/crates/fspy_shared_unix/src/spawn/linux/mod.rs +++ b/crates/fspy_shared_unix/src/spawn/linux/mod.rs @@ -14,6 +14,11 @@ const LD_PRELOAD: &str = "LD_PRELOAD"; pub struct PreExec(SeccompPayload); impl PreExec { + /// Installs the seccomp unotify filter for the current process. + /// + /// # Errors + /// + /// Returns an error if the seccomp filter installation fails. pub fn run(&self) -> nix::Result<()> { install_target(&self.0) } @@ -24,6 +29,7 @@ pub fn handle_exec( encoded_payload: &EncodedPayload, ) -> nix::Result> { let executable_fd = open_executable(Path::new(OsStr::from_bytes(&command.program)))?; + // SAFETY: The file descriptor is valid and we only read from the mapping. let executable_mmap = unsafe { Mmap::map(&executable_fd) } .map_err(|io_error| nix::Error::try_from(io_error).unwrap_or(nix::Error::UnknownErrno))?; if elf::is_dynamically_linked_to_libc(executable_mmap)? { diff --git a/crates/fspy_test_bin/src/main.rs b/crates/fspy_test_bin/src/main.rs index 81ffa7e1..35e54856 100644 --- a/crates/fspy_test_bin/src/main.rs +++ b/crates/fspy_test_bin/src/main.rs @@ -1,3 +1,4 @@ +#[expect(clippy::unimplemented, reason = "fspy_test_bin is a test-only binary for Linux")] #[cfg(not(target_os = "linux"))] fn main() { unimplemented!("fspy_test_bin is only for Linux"); @@ -42,6 +43,6 @@ fn main() { "execve" => { let _ = std::process::Command::new(path).spawn(); } - _ => panic!("unknown action: {}", action), + _ => panic!("unknown action: {action}"), } } diff --git a/crates/fspy_test_utils/src/lib.rs b/crates/fspy_test_utils/src/lib.rs index 0b17b254..c4f59da6 100644 --- a/crates/fspy_test_utils/src/lib.rs +++ b/crates/fspy_test_utils/src/lib.rs @@ -65,6 +65,7 @@ mod tests { use std::str::from_utf8; #[test] + #[expect(clippy::print_stdout, reason = "test diagnostics")] fn test_command_executing() { let mut command = command_executing!(42u32, |arg: u32| { print!("{arg}"); diff --git a/crates/vite_glob/src/lib.rs b/crates/vite_glob/src/lib.rs index e04f6572..dc8b03b8 100644 --- a/crates/vite_glob/src/lib.rs +++ b/crates/vite_glob/src/lib.rs @@ -1,5 +1,6 @@ mod error; +#[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] use std::path::Path; pub use error::Error; @@ -15,6 +16,8 @@ pub struct GlobPatternSet<'a> { } impl<'a> GlobPatternSet<'a> { + /// # Errors + /// Returns an error if any glob pattern is invalid. pub fn new(match_patterns: I) -> Result where I: IntoIterator, @@ -37,6 +40,7 @@ impl<'a> GlobPatternSet<'a> { Ok(Self { patterns, has_negated }) } + #[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] pub fn is_match(&self, path: impl AsRef) -> bool { let mut should_match = false; // Default: don't match for (glob, match_or_not) in &self.patterns { @@ -107,8 +111,11 @@ mod tests { assert!(ignores.is_match("error.log")); assert!(ignores.is_match("temp/file.tmp")); assert!(ignores.is_match("deep/nested/path/cache.tmp")); - assert!(ignores.is_match(String::from("deep/nested/path/cache.tmp"))); - assert!(ignores.is_match(Path::new("deep/nested/path/cache.tmp"))); + #[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] + { + assert!(ignores.is_match(String::from("deep/nested/path/cache.tmp"))); + assert!(ignores.is_match(Path::new("deep/nested/path/cache.tmp"))); + } // Should NOT ignore negated patterns assert!(!ignores.is_match("important.log")); @@ -297,6 +304,10 @@ mod tests { Ok(()) } + #[expect( + clippy::disallowed_types, + reason = "tests that is_match accepts various argument types" + )] #[test] fn test_generic_api_with_different_types() -> Result<(), Error> { use vite_str::Str; diff --git a/crates/vite_graph_ser/src/lib.rs b/crates/vite_graph_ser/src/lib.rs index edc4fd5e..3dd6edd7 100644 --- a/crates/vite_graph_ser/src/lib.rs +++ b/crates/vite_graph_ser/src/lib.rs @@ -10,6 +10,9 @@ pub trait GetKey { type Key<'a>: Serialize + Ord where Self: 'a; + /// # Errors + /// Returns an error if the key cannot be computed. + #[expect(clippy::disallowed_types, reason = "trait error type is String for simplicity")] fn key(&self) -> Result, String>; } @@ -36,6 +39,12 @@ pub struct SerializeByKey<'a, N: GetKey + Serialize, E: Serialize, Ix: petgraph: /// an error will be returned. /// /// This is useful for serializing graphs in a stable and human-readable way. +/// +/// # Errors +/// Returns a serialization error if the graph cannot be serialized. +/// +/// # Panics +/// Panics if an edge references a node index not present in the graph. pub fn serialize_by_key< N: GetKey + Serialize, E: Serialize, @@ -83,6 +92,7 @@ mod tests { where Self: 'a; + #[expect(clippy::disallowed_types, reason = "trait requires String error type")] fn key(&self) -> Result, String> { Ok(self.id) } diff --git a/crates/vite_path/src/absolute/mod.rs b/crates/vite_path/src/absolute/mod.rs index 4e0ed98c..794eb93b 100644 --- a/crates/vite_path/src/absolute/mod.rs +++ b/crates/vite_path/src/absolute/mod.rs @@ -71,6 +71,9 @@ impl From<&AbsolutePath> for Arc { fn from(path: &AbsolutePath) -> Self { let arc: Arc = path.0.into(); let arc_raw = Arc::into_raw(arc) as *const AbsolutePath; + // SAFETY: AbsolutePath is #[repr(transparent)] over Path, so the pointer cast + // from Arc to Arc preserves layout. The source path is + // already verified absolute since it comes from an &AbsolutePath. unsafe { Self::from_raw(arc_raw) } } } @@ -79,6 +82,9 @@ impl From<&AbsolutePath> for Box { fn from(path: &AbsolutePath) -> Self { let path_box: Box = path.0.into(); let path_box_raw = Box::into_raw(path_box) as *mut AbsolutePath; + // SAFETY: AbsolutePath is #[repr(transparent)] over Path, so the pointer cast + // from Box to Box preserves layout. The source path is + // already verified absolute since it comes from an &AbsolutePath. unsafe { Self::from_raw(path_box_raw) } } } @@ -87,10 +93,20 @@ impl AbsolutePath { /// Creates a [`AbsolutePath`] if the give path is absolute. pub fn new + ?Sized>(path: &P) -> Option<&Self> { let path = path.as_ref(); - if path.is_absolute() { Some(unsafe { Self::assume_absolute(path) }) } else { None } + if path.is_absolute() { + // SAFETY: We just verified that path.is_absolute() is true. + Some(unsafe { Self::assume_absolute(path) }) + } else { + None + } } #[cfg(feature = "absolute-redaction")] + #[expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "try_redact returns std String and uses std format!" + )] fn try_redact(&self) -> Result, String> { use redaction::REDACTION_PREFIX; @@ -126,6 +142,7 @@ impl AbsolutePath { /// Converts `self` to an owned [`AbsolutePathBuf`]. #[must_use] pub fn to_absolute_path_buf(&self) -> AbsolutePathBuf { + // SAFETY: self is already an AbsolutePath, so its path data is absolute. unsafe { AbsolutePathBuf::assume_absolute(self.0.to_path_buf()) } } @@ -168,12 +185,14 @@ impl AbsolutePath { #[must_use] pub fn parent(&self) -> Option<&Self> { let parent_path = self.0.parent()?; + // SAFETY: The parent of an absolute path is always absolute. Some(unsafe { Self::assume_absolute(parent_path) }) } /// Creates an owned [`AbsolutePathBuf`] like `self` but with the extension added. pub fn with_extension>(&self, extension: S) -> AbsolutePathBuf { let path = self.0.with_extension(extension); + // SAFETY: Changing the extension of an absolute path preserves its absoluteness. unsafe { AbsolutePathBuf::assume_absolute(path) } } @@ -215,6 +234,9 @@ impl From for Arc { fn from(path: AbsolutePathBuf) -> Self { let arc: Arc = path.0.into(); let arc_raw = Arc::into_raw(arc) as *const AbsolutePath; + // SAFETY: AbsolutePath is #[repr(transparent)] over Path, so the pointer cast + // from Arc to Arc preserves layout. The source path is + // already verified absolute since it comes from an AbsolutePathBuf. unsafe { Self::from_raw(arc_raw) } } } @@ -222,9 +244,16 @@ impl From for Arc { impl AbsolutePathBuf { #[must_use] pub fn new(path: PathBuf) -> Option { - if path.is_absolute() { Some(unsafe { Self::assume_absolute(path) }) } else { None } + if path.is_absolute() { + // SAFETY: We just verified that path.is_absolute() is true. + Some(unsafe { Self::assume_absolute(path) }) + } else { + None + } } + /// # Safety + /// The caller must ensure that `abs_path` is an absolute path. #[must_use] pub const unsafe fn assume_absolute(abs_path: PathBuf) -> Self { Self(abs_path) @@ -232,6 +261,7 @@ impl AbsolutePathBuf { #[must_use] pub fn as_absolute_path(&self) -> &AbsolutePath { + // SAFETY: self is an AbsolutePathBuf, so its inner PathBuf is guaranteed absolute. unsafe { AbsolutePath::assume_absolute(self.0.as_path()) } } diff --git a/crates/vite_path/src/absolute/redaction.rs b/crates/vite_path/src/absolute/redaction.rs index 5bc81281..ee4d8dfc 100644 --- a/crates/vite_path/src/absolute/redaction.rs +++ b/crates/vite_path/src/absolute/redaction.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::AbsolutePath; thread_local! { - pub(crate) static REDACTION_PREFIX: std::cell::RefCell>> = std::cell::RefCell::new(None); + pub(crate) static REDACTION_PREFIX: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } #[derive(Debug)] @@ -15,11 +15,14 @@ impl Drop for RedactionGuard { } } +/// # Panics +/// Panics if a `RedactionGuard` is already active. +#[must_use] pub fn redact_absolute_paths(prefix: &Arc) -> RedactionGuard { REDACTION_PREFIX.with(|redaction_prefix| { let mut redaction_prefix = redaction_prefix.borrow_mut(); assert!(redaction_prefix.is_none(), "RedactionGuard already active"); - *redaction_prefix = Some(Arc::clone(&prefix)); + *redaction_prefix = Some(Arc::clone(prefix)); }); RedactionGuard(()) } diff --git a/crates/vite_path/src/lib.rs b/crates/vite_path/src/lib.rs index 87a3808f..46c6d70a 100644 --- a/crates/vite_path/src/lib.rs +++ b/crates/vite_path/src/lib.rs @@ -1,3 +1,5 @@ +#![expect(clippy::disallowed_types, reason = "vite_path needs to use std path types internally")] + pub mod absolute; pub mod relative; @@ -20,6 +22,10 @@ pub use relative::{RelativePath, RelativePathBuf}; /// /// Panics if `std::env::current_dir()` returns a non-absolute path, which should never happen in practice. pub fn current_dir() -> io::Result { + #[expect( + clippy::disallowed_methods, + reason = "std current_dir needed to get the current working directory as an absolute path" + )] let cwd = std::env::current_dir()?; // `std::env::current_dir` should always return a absolute path but its documentation doesn't guarantee that. // Do a runtime check just in case. diff --git a/crates/vite_path/src/relative.rs b/crates/vite_path/src/relative.rs index de76ca1f..548b8017 100644 --- a/crates/vite_path/src/relative.rs +++ b/crates/vite_path/src/relative.rs @@ -71,6 +71,8 @@ impl RelativePath { /// Panics if the stripped path contains non-UTF-8 characters, which should not happen for valid `RelativePath` instances. pub fn strip_prefix>(&self, base: P) -> Option<&Self> { let stripped_path = Path::new(self.as_str()).strip_prefix(base.as_ref().as_path()).ok()?; + // SAFETY: The stripped result of a portable RelativePath is still portable: + // it remains valid UTF-8 and contains no backslash separators. Some(unsafe { Self::assume_portable(stripped_path.to_str().unwrap()) }) } } @@ -79,7 +81,10 @@ impl RelativePath { #[derive( Debug, Encode, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, Default, )] -#[expect(clippy::unsafe_derive_deserialize)] +#[expect( + clippy::unsafe_derive_deserialize, + reason = "unsafe in Decode impl validates portability invariants" +)] pub struct RelativePathBuf(Str); impl AsRef for RelativePathBuf { @@ -183,15 +188,23 @@ impl RelativePathBuf { #[must_use] pub fn as_relative_path(&self) -> &RelativePath { + // SAFETY: RelativePathBuf's constructors (new, Decode) validate portability + // invariants, so the inner string is guaranteed to be a valid portable path. unsafe { RelativePath::assume_portable(&self.0) } } } -impl<'a, Context> Decode for RelativePathBuf { +impl Decode for RelativePathBuf { fn decode>(decoder: &mut D) -> Result { let path_str = Str::decode(decoder)?; - Self::new(path_str.as_str()) - .map_err(|err| DecodeError::OtherString(format!("{err}: {path_str}"))) + Self::new(path_str.as_str()).map_err(|err| { + #[expect( + clippy::disallowed_macros, + reason = "bincode::error::DecodeError requires std format!" + )] + let msg = format!("{err}: {path_str}"); + DecodeError::OtherString(msg) + }) } } @@ -269,6 +282,7 @@ mod ts_impl { use super::RelativePathBuf; + #[expect(clippy::disallowed_types, reason = "ts_rs::TS trait requires returning std String")] impl TS for RelativePathBuf { type OptionInnerType = Self; type WithoutGenerics = Self; @@ -318,10 +332,10 @@ mod tests { fn non_utf8() { use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _}; - let non_utf8_path = Path::new(OsStr::from_bytes(&[0xC0])); + let non_utf8_os_str = OsStr::from_bytes(&[0xC0]); let_assert!( Err(FromPathError::InvalidPathData(InvalidPathDataError::NonUtf8)) = - RelativePathBuf::new(non_utf8_path), + RelativePathBuf::new(non_utf8_os_str), ); } @@ -380,7 +394,7 @@ mod tests { #[test] fn push() { let mut rel_path = RelativePathBuf::new("foo/bar").unwrap(); - rel_path.push(RelativePathBuf::new(Path::new("baz")).unwrap()); + rel_path.push(RelativePathBuf::new("baz").unwrap()); assert_eq!(rel_path.as_str(), "foo/bar/baz"); } diff --git a/crates/vite_shell/src/lib.rs b/crates/vite_shell/src/lib.rs index 15c6b93a..8baca49c 100644 --- a/crates/vite_shell/src/lib.rs +++ b/crates/vite_shell/src/lib.rs @@ -42,6 +42,7 @@ impl Display for TaskParsedCommand { } } +#[expect(clippy::disallowed_types, reason = "brush_parser::unquote_str returns String")] fn unquote(word: &Word) -> String { let Word { value, loc: _ } = word; unquote_str(value.as_str()) @@ -92,6 +93,7 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range< Some((TaskParsedCommand { envs, program: unquote(program).into(), args }, range)) } +#[must_use] pub fn try_parse_as_and_list(cmd: &str) -> Option)>> { let mut parser = Parser::new( cmd.as_bytes(), @@ -128,7 +130,7 @@ mod tests { #[test] fn test_parse_single_command() { - let source = r#"A=B hello world"#; + let source = r"A=B hello world"; let list = try_parse_as_and_list(source).unwrap(); assert_eq!(list.len(), 1); let (cmd, range) = &list[0]; diff --git a/crates/vite_str/src/lib.rs b/crates/vite_str/src/lib.rs index eee7882f..e54c3633 100644 --- a/crates/vite_str/src/lib.rs +++ b/crates/vite_str/src/lib.rs @@ -1,3 +1,4 @@ +#[expect(clippy::disallowed_types, reason = "vite_str defines Str using std types internally")] use std::{ borrow::Borrow, ffi::OsStr, @@ -79,7 +80,9 @@ impl AsRef for Str { self.0.as_ref() } } +#[expect(clippy::disallowed_types, reason = "vite_str provides Path interop via AsRef")] impl AsRef for Str { + #[expect(clippy::disallowed_types, reason = "fn signature uses std Path")] fn as_ref(&self) -> &Path { self.0.as_ref() } @@ -130,6 +133,7 @@ impl Decode for Str { decoder.claim_container_read::(len)?; let mut compact_str = CompactString::with_capacity(len); + // SAFETY: we write exactly `len` bytes into the spare capacity, validate UTF-8, then set length unsafe { let buf = &mut compact_str.as_mut_bytes()[..len]; decoder.reader().read(buf)?; @@ -147,7 +151,9 @@ impl From<&str> for Str { } } +#[expect(clippy::disallowed_types, reason = "vite_str provides String conversion via From")] impl From for Str { + #[expect(clippy::disallowed_types, reason = "fn signature uses std String")] fn from(value: String) -> Self { Self(value.into()) } @@ -161,7 +167,7 @@ impl From for Str { impl From for Arc { fn from(value: Str) -> Self { - Arc::from(value.as_str()) + Self::from(value.as_str()) } } @@ -182,6 +188,7 @@ mod ts_impl { use super::Str; + #[expect(clippy::disallowed_types, reason = "ts-rs trait requires returning String")] impl TS for Str { type OptionInnerType = Self; type WithoutGenerics = Self; diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index e319b82a..9192f1e5 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -26,6 +26,7 @@ owo-colors = { workspace = true } petgraph = { workspace = true } rayon = { workspace = true } rusqlite = { workspace = true, features = ["bundled"] } +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/vite_task/src/cli/mod.rs b/crates/vite_task/src/cli/mod.rs index 5abdbc95..5e40ff69 100644 --- a/crates/vite_task/src/cli/mod.rs +++ b/crates/vite_task/src/cli/mod.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{iter, sync::Arc}; use clap::Parser; use vite_path::AbsolutePath; @@ -58,17 +58,17 @@ pub enum CLITaskQueryError { impl RunCommand { /// Convert to `PlanRequest`, or return an error if invalid. + /// + /// # Errors + /// + /// Returns an error if `--recursive` and `--transitive` are both set, + /// or if a package name is specified with `--recursive`. pub fn into_plan_request( self, cwd: &Arc, ) -> Result { - let RunCommand { - task_specifier, - recursive, - transitive, - ignore_depends_on, - additional_args, - } = self; + let Self { task_specifier, recursive, transitive, ignore_depends_on, additional_args } = + self; let include_explicit_deps = !ignore_depends_on; @@ -84,10 +84,10 @@ impl RunCommand { } else { task_specifier.task_name }; - TaskQueryKind::Recursive { task_names: [task_name].into() } + TaskQueryKind::Recursive { task_names: iter::once(task_name).collect() } } else { TaskQueryKind::Normal { - task_specifiers: [task_specifier].into(), + task_specifiers: iter::once(task_specifier).collect(), cwd: Arc::clone(cwd), include_topological_deps: transitive, } diff --git a/crates/vite_task/src/collections.rs b/crates/vite_task/src/collections.rs index ff08958b..ec01f380 100644 --- a/crates/vite_task/src/collections.rs +++ b/crates/vite_task/src/collections.rs @@ -1,2 +1,2 @@ -#[expect(clippy::disallowed_types)] +#[expect(clippy::disallowed_types, reason = "std HashMap needed for bincode/serde compatibility")] pub type HashMap = std::collections::HashMap; diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index 0d86d9d2..bf391971 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -13,6 +13,6 @@ pub use vite_task_graph::{ }, loader, }; -/// Re-exports useful for CommandHandler implementations. +/// Re-exports useful for `CommandHandler` implementations. pub use vite_task_plan::get_path_env; pub use vite_task_plan::{plan_request, plan_request::ScriptCommand}; diff --git a/crates/vite_task/src/maybe_str.rs b/crates/vite_task/src/maybe_str.rs index e2f86af6..53bca7d2 100644 --- a/crates/vite_task/src/maybe_str.rs +++ b/crates/vite_task/src/maybe_str.rs @@ -11,7 +11,7 @@ use serde::Serialize; /// and serializes losslessly to utf8 for outputting debug json #[derive(Encode, Decode)] -#[allow(dead_code)] +#[expect(dead_code, reason = "struct fields accessed via Deref>")] pub struct MaybeString(Vec); impl From> for MaybeString { diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index c194f934..e2e4b573 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -1,10 +1,10 @@ //! Human-readable formatting for cache status //! //! This module provides plain text formatting for cache status. -//! Coloring is handled by the reporter to respect NO_COLOR environment variable. - -use std::collections::HashSet; +//! Coloring is handled by the reporter to respect `NO_COLOR` environment variable. +use rustc_hash::FxHashSet; +use vite_str::Str; use vite_task_plan::cache_metadata::SpawnFingerprint; use super::{CacheMiss, FingerprintMismatch}; @@ -14,17 +14,17 @@ use crate::session::event::{CacheDisabledReason, CacheStatus}; enum SpawnFingerprintChange { // Environment variable changes /// Environment variable added - EnvAdded { key: String, value: String }, + EnvAdded { key: Str, value: Str }, /// Environment variable removed - EnvRemoved { key: String, value: String }, + EnvRemoved { key: Str, value: Str }, /// Environment variable value changed - EnvValueChanged { key: String, old_value: String, new_value: String }, + EnvValueChanged { key: Str, old_value: Str, new_value: Str }, // Pass-through env config changes /// Pass-through env pattern added - PassThroughEnvAdded { name: String }, + PassThroughEnvAdded { name: Str }, /// Pass-through env pattern removed - PassThroughEnvRemoved { name: String }, + PassThroughEnvRemoved { name: Str }, // Command changes /// Program changed @@ -38,9 +38,9 @@ enum SpawnFingerprintChange { // Fingerprint ignores changes /// Fingerprint ignore pattern added - FingerprintIgnoreAdded { pattern: String }, + FingerprintIgnoreAdded { pattern: Str }, /// Fingerprint ignore pattern removed - FingerprintIgnoreRemoved { pattern: String }, + FingerprintIgnoreRemoved { pattern: Str }, } /// Compare two spawn fingerprints and return all changes @@ -57,15 +57,15 @@ fn detect_spawn_fingerprint_changes( if let Some(new_value) = new_env.fingerprinted_envs.get(key) { if old_value != new_value { changes.push(SpawnFingerprintChange::EnvValueChanged { - key: key.to_string(), - old_value: old_value.to_string(), - new_value: new_value.to_string(), + key: key.clone(), + old_value: Str::from(old_value.as_ref()), + new_value: Str::from(new_value.as_ref()), }); } } else { changes.push(SpawnFingerprintChange::EnvRemoved { - key: key.to_string(), - value: old_value.to_string(), + key: key.clone(), + value: Str::from(old_value.as_ref()), }); } } @@ -74,20 +74,20 @@ fn detect_spawn_fingerprint_changes( for (key, new_value) in &new_env.fingerprinted_envs { if !old_env.fingerprinted_envs.contains_key(key) { changes.push(SpawnFingerprintChange::EnvAdded { - key: key.to_string(), - value: new_value.to_string(), + key: key.clone(), + value: Str::from(new_value.as_ref()), }); } } // Check pass-through env config changes - let old_pass_through: HashSet<_> = old_env.pass_through_env_config.iter().collect(); - let new_pass_through: HashSet<_> = new_env.pass_through_env_config.iter().collect(); + let old_pass_through: FxHashSet<_> = old_env.pass_through_env_config.iter().collect(); + let new_pass_through: FxHashSet<_> = new_env.pass_through_env_config.iter().collect(); for name in old_pass_through.difference(&new_pass_through) { - changes.push(SpawnFingerprintChange::PassThroughEnvRemoved { name: name.to_string() }); + changes.push(SpawnFingerprintChange::PassThroughEnvRemoved { name: (*name).clone() }); } for name in new_pass_through.difference(&old_pass_through) { - changes.push(SpawnFingerprintChange::PassThroughEnvAdded { name: name.to_string() }); + changes.push(SpawnFingerprintChange::PassThroughEnvAdded { name: (*name).clone() }); } // Check program changes @@ -106,18 +106,17 @@ fn detect_spawn_fingerprint_changes( } // Check fingerprint ignores changes - let old_ignores: HashSet<_> = + let old_ignores: FxHashSet<_> = old.fingerprint_ignores().map(|v| v.iter().collect()).unwrap_or_default(); - let new_ignores: HashSet<_> = + let new_ignores: FxHashSet<_> = new.fingerprint_ignores().map(|v| v.iter().collect()).unwrap_or_default(); for pattern in old_ignores.difference(&new_ignores) { - changes.push(SpawnFingerprintChange::FingerprintIgnoreRemoved { - pattern: pattern.to_string(), - }); + changes + .push(SpawnFingerprintChange::FingerprintIgnoreRemoved { pattern: (*pattern).clone() }); } for pattern in new_ignores.difference(&old_ignores) { changes - .push(SpawnFingerprintChange::FingerprintIgnoreAdded { pattern: pattern.to_string() }); + .push(SpawnFingerprintChange::FingerprintIgnoreAdded { pattern: (*pattern).clone() }); } changes @@ -125,18 +124,18 @@ fn detect_spawn_fingerprint_changes( /// Format cache status for inline display (during Start event). /// -/// Returns Some(formatted_string) for Hit, Miss with reason, and Disabled, None for NotFound. +/// Returns `Some(formatted_string)` for Hit, Miss with reason, and Disabled, None for `NotFound`. /// - Cache Hit: Shows "cache hit" indicator /// - Cache Miss (NotFound): No inline message (just command) /// - Cache Miss (with mismatch): Shows "cache miss" with brief reason /// - Cache Disabled: Shows "cache disabled" with reason /// /// Note: Returns plain text without styling. The reporter applies colors. -pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { +pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { match cache_status { CacheStatus::Hit { .. } => { // Show "cache hit" indicator when replaying from cache - Some("✓ cache hit, replaying".to_string()) + Some(Str::from("✓ cache hit, replaying")) } CacheStatus::Miss(CacheMiss::NotFound) => { // No inline message for "not found" case - just show command @@ -172,14 +171,14 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { PostRunFingerprintMismatch::InputContentChanged { path } => { - return Some(format!( + return Some(vite_str::format!( "✗ cache miss: content of input '{path}' changed, executing" )); } } } }; - Some(format!("✗ cache miss: {reason}, executing")) + Some(vite_str::format!("✗ cache miss: {reason}, executing")) } CacheStatus::Disabled(reason) => { // Show inline message for disabled cache @@ -188,7 +187,7 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option CacheDisabledReason::NoCacheMetadata => "cache disabled: no cache config", CacheDisabledReason::CycleDetected => "cache disabled: cycle detected", }; - Some(format!("⊘ {message}")) + Some(vite_str::format!("⊘ {message}")) } } } @@ -202,63 +201,65 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option /// - Cache Disabled: Shows user-friendly reason message /// /// Note: Returns plain text without styling. The reporter applies colors. -pub fn format_cache_status_summary(cache_status: &CacheStatus) -> String { +pub fn format_cache_status_summary(cache_status: &CacheStatus) -> Str { match cache_status { CacheStatus::Hit { replayed_duration } => { // Show saved time for cache hits - format!("→ Cache hit - output replayed - {replayed_duration:.2?} saved") + vite_str::format!("→ Cache hit - output replayed - {replayed_duration:.2?} saved") } CacheStatus::Miss(CacheMiss::NotFound) => { // First time running this task - no previous cache entry - "→ Cache miss: no previous cache entry found".to_string() + Str::from("→ Cache miss: no previous cache entry found") } CacheStatus::Miss(CacheMiss::FingerprintMismatch(mismatch)) => { // Show specific reason why cache was invalidated match mismatch { FingerprintMismatch::SpawnFingerprintMismatch { old, new } => { let changes = detect_spawn_fingerprint_changes(old, new); - let formatted: Vec = changes + let formatted: Vec = changes .iter() .map(|c| match c { SpawnFingerprintChange::EnvAdded { key, value } => { - format!("env {key}={value} added") + vite_str::format!("env {key}={value} added") } SpawnFingerprintChange::EnvRemoved { key, value } => { - format!("env {key}={value} removed") + vite_str::format!("env {key}={value} removed") } SpawnFingerprintChange::EnvValueChanged { key, old_value, new_value, } => { - format!( + vite_str::format!( "env {key} value changed from '{old_value}' to '{new_value}'" ) } SpawnFingerprintChange::PassThroughEnvAdded { name } => { - format!("pass-through env '{name}' added") + vite_str::format!("pass-through env '{name}' added") } SpawnFingerprintChange::PassThroughEnvRemoved { name } => { - format!("pass-through env '{name}' removed") + vite_str::format!("pass-through env '{name}' removed") } - SpawnFingerprintChange::ProgramChanged => "program changed".to_string(), - SpawnFingerprintChange::ArgsChanged => "args changed".to_string(), + SpawnFingerprintChange::ProgramChanged => Str::from("program changed"), + SpawnFingerprintChange::ArgsChanged => Str::from("args changed"), SpawnFingerprintChange::CwdChanged => { - "working directory changed".to_string() + Str::from("working directory changed") } SpawnFingerprintChange::FingerprintIgnoreAdded { pattern } => { - format!("fingerprint ignore '{pattern}' added") + vite_str::format!("fingerprint ignore '{pattern}' added") } SpawnFingerprintChange::FingerprintIgnoreRemoved { pattern } => { - format!("fingerprint ignore '{pattern}' removed") + vite_str::format!("fingerprint ignore '{pattern}' removed") } }) .collect(); if formatted.is_empty() { - "→ Cache miss: configuration changed".to_string() + Str::from("→ Cache miss: configuration changed") } else { - format!("→ Cache miss: {}", formatted.join("; ")) + let joined = + formatted.iter().map(Str::as_str).collect::>().join("; "); + vite_str::format!("→ Cache miss: {joined}") } } FingerprintMismatch::PostRunFingerprintMismatch(diff) => { @@ -266,7 +267,7 @@ pub fn format_cache_status_summary(cache_status: &CacheStatus) -> String { use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { PostRunFingerprintMismatch::InputContentChanged { path } => { - format!("→ Cache miss: content of input '{path}' changed") + vite_str::format!("→ Cache miss: content of input '{path}' changed") } } } @@ -279,7 +280,7 @@ pub fn format_cache_status_summary(cache_status: &CacheStatus) -> String { CacheDisabledReason::NoCacheMetadata => "Cache disabled in task configuration", CacheDisabledReason::CycleDetected => "Cache disabled: cycle detected", }; - format!("→ {message}") + vite_str::format!("→ {message}") } } } diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index a44e8d53..c9bf5381 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -10,7 +10,7 @@ pub use display::{format_cache_status_inline, format_cache_status_summary}; use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_path::AbsolutePath; use vite_task_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}; use super::execute::{ @@ -35,12 +35,20 @@ pub struct ExecutionCache { const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); #[derive(Debug, Serialize, Deserialize)] +#[expect( + clippy::large_enum_variant, + reason = "FingerprintMismatch contains SpawnFingerprint which is intentionally large; boxing would add unnecessary indirection for a short-lived enum" +)] pub enum CacheMiss { NotFound, FingerprintMismatch(FingerprintMismatch), } #[derive(Debug, Serialize, Deserialize)] +#[expect( + clippy::large_enum_variant, + reason = "SpawnFingerprintMismatch holds two SpawnFingerprints for comparison; boxing would add unnecessary indirection for a short-lived enum" +)] pub enum FingerprintMismatch { /// Found the cache entry of the same task run, but the spawn fingerprint mismatches /// this happens when the command itself or an env changes. @@ -66,14 +74,17 @@ impl Display for FingerprintMismatch { } impl ExecutionCache { - pub fn load_from_path(cache_path: AbsolutePathBuf) -> anyhow::Result { - let path: &AbsolutePath = cache_path.as_ref(); + pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result { tracing::info!("Creating task cache directory at {:?}", path); std::fs::create_dir_all(path)?; // Use file lock to prevent race conditions when multiple processes initialize the database let lock_path = path.join("db_open.lock"); let lock_file = File::create(lock_path.as_path())?; + #[expect( + clippy::incompatible_msrv, + reason = "File::lock is stable since 1.84.0, our MSRV 1.88.0 is higher; clippy false positive" + )] lock_file.lock()?; let db_path = path.join("cache.db"); @@ -101,8 +112,8 @@ impl ExecutionCache { conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; } 6 => break, // current version - 6.. => { - return Err(anyhow::anyhow!("Unrecognized database version: {}", user_version)); + 7.. => { + return Err(anyhow::anyhow!("Unrecognized database version: {user_version}")); } } } @@ -179,18 +190,33 @@ impl ExecutionCache { // Basic database operations impl ExecutionCache { + #[expect( + clippy::future_not_send, + reason = "tokio MutexGuard is !Send but this future only runs on a single-threaded runtime" + )] + #[expect( + clippy::significant_drop_tightening, + reason = "lock guard cannot be dropped earlier because prepared statement borrows connection" + )] async fn get_key_by_value>( &self, table: &str, key: &K, ) -> anyhow::Result> { - let conn = self.conn.lock().await; - let mut select_stmt = - conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; - let Some(value_blob) = - select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()? - else { + let value_blob = { + let conn = self.conn.lock().await; + #[expect( + clippy::disallowed_macros, + reason = "SQL query string for rusqlite requires String" + )] + let mut select_stmt = + conn.prepare_cached(&format!("SELECT value FROM {table} WHERE key=?"))?; + let value_blob: Option> = + select_stmt.query_row::, _, _>([key_blob], |row| row.get(0)).optional()?; + value_blob + }; + let Some(value_blob) = value_blob else { return Ok(None); }; let (value, _) = decode_from_slice::(&value_blob, BINCODE_CONFIG)?; @@ -211,15 +237,24 @@ impl ExecutionCache { self.get_key_by_value("execution_key_to_fingerprint", execution_cache_key).await } + #[expect( + clippy::future_not_send, + reason = "tokio MutexGuard is !Send but this future only runs on a single-threaded runtime" + )] + #[expect( + clippy::significant_drop_tightening, + reason = "lock guard must be held while executing the prepared statement" + )] async fn upsert( &self, table: &str, key: &K, value: &V, ) -> anyhow::Result<()> { - let conn = self.conn.lock().await; let key_blob = encode_to_vec(key, BINCODE_CONFIG)?; let value_blob = encode_to_vec(value, BINCODE_CONFIG)?; + let conn = self.conn.lock().await; + #[expect(clippy::disallowed_macros, reason = "SQL query string for rusqlite requires String")] let mut update_stmt = conn.prepare_cached(&format!( "INSERT INTO {table} (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value=?2" ))?; @@ -243,12 +278,20 @@ impl ExecutionCache { self.upsert("execution_key_to_fingerprint", execution_cache_key, spawn_fingerprint).await } + #[expect( + clippy::significant_drop_tightening, + reason = "lock guard must be held while iterating over query rows" + )] async fn list_table + Serialize, V: Decode<()> + Serialize>( &self, table: &str, out: &mut impl Write, ) -> anyhow::Result<()> { let conn = self.conn.lock().await; + #[expect( + clippy::disallowed_macros, + reason = "SQL query string for rusqlite requires String" + )] let mut select_stmt = conn.prepare_cached(&format!("SELECT key, value FROM {table}"))?; let mut rows = select_stmt.query([])?; while let Some(row) = rows.next()? { diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 37d68580..e8e47604 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -1,6 +1,7 @@ use std::{process::ExitStatus, time::Duration}; use bstr::BString; +use vite_str::Str; // Re-export ExecutionItemDisplay from vite_task_plan since it's the canonical definition pub use vite_task_plan::ExecutionItemDisplay; @@ -38,21 +39,25 @@ pub enum CacheUpdateStatus { } #[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "CacheMiss variant is intentionally large and infrequently cloned" +)] pub enum CacheStatus { Disabled(CacheDisabledReason), Miss(CacheMiss), Hit { replayed_duration: Duration }, } -/// Convert ExitStatus to an i32 exit code. -/// On Unix, if terminated by signal, returns 128 + signal_number. -pub fn exit_status_to_code(status: &ExitStatus) -> i32 { +/// Convert `ExitStatus` to an i32 exit code. +/// On Unix, if terminated by signal, returns 128 + `signal_number`. +pub fn exit_status_to_code(status: ExitStatus) -> i32 { #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; status.code().unwrap_or_else(|| { // Process was terminated by signal, use Unix convention: 128 + signal - status.signal().map(|sig| 128 + sig).unwrap_or(1) + status.signal().map_or(1, |sig| 128 + sig) }) } #[cfg(not(unix))] @@ -66,11 +71,11 @@ pub fn exit_status_to_code(status: &ExitStatus) -> i32 { pub struct ExecutionId(u32); impl ExecutionId { - pub(crate) fn zero() -> Self { + pub(crate) const fn zero() -> Self { Self(0) } - pub(crate) fn next(&self) -> Self { + pub(crate) const fn next(self) -> Self { Self(self.0.checked_add(1).expect("ExecutionId overflow")) } } @@ -82,9 +87,13 @@ pub struct ExecutionEvent { } #[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "event variants are consumed once and not stored in collections" +)] pub enum ExecutionEventKind { Start { display: Option, cache_status: CacheStatus }, Output { kind: OutputKind, content: BString }, - Error { message: String }, + Error { message: Str }, Finish { status: Option, cache_update_status: CacheUpdateStatus }, } diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index 9a708c05..25e65256 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -33,7 +33,7 @@ pub struct PostRunFingerprint { pub enum PathFingerprint { /// Path was not found when fingerprinting NotFound, - /// File content hash using xxHash3_64 + /// File content hash using `xxHash3_64` FileContentHash(u64), /// Directory with optional entry listing. /// `Folder(None)` means the directory was opened but entries were not read @@ -88,11 +88,7 @@ impl PostRunFingerprint { .par_iter() .filter(|(path, _)| { // Apply ignore patterns if present - if let Some(ref matcher) = ignore_matcher { - !matcher.is_match(path.as_str()) - } else { - true - } + ignore_matcher.as_ref().is_none_or(|matcher| !matcher.is_match(path.as_str())) }) .map(|(relative_path, path_read)| { let full_path = Arc::::from(base_dir.join(relative_path)); @@ -132,7 +128,7 @@ impl PostRunFingerprint { } } -/// Hash file content using xxHash3_64 +/// Hash file content using `xxHash3_64` fn hash_content(mut stream: impl Read) -> io::Result { let mut hasher = twox_hash::XxHash3_64::default(); let mut buf = [0u8; 8192]; @@ -160,7 +156,6 @@ pub fn fingerprint_path( let file = match File::open(std_path) { Ok(file) => file, - #[allow(unused)] Err(err) => { // On Windows, File::open fails specifically for directories with PermissionDenied #[cfg(windows)] @@ -195,7 +190,7 @@ pub fn fingerprint_path( // Is a directory on Unix - use the optimized nix implementation #[cfg(unix)] { - return process_directory_unix(reader.into_inner(), path_read); + return process_directory_unix(reader.get_ref(), path_read); } #[cfg(windows)] { @@ -205,8 +200,9 @@ pub fn fingerprint_path( Ok(PathFingerprint::FileContentHash(hash_content(reader)?)) } -/// Process a directory on Windows using std::fs::read_dir +/// Process a directory on Windows using `std::fs::read_dir` #[cfg(windows)] +#[expect(clippy::disallowed_types, reason = "Windows fallback uses std::path::Path directly")] fn process_directory( path: &std::path::Path, path_read: PathRead, @@ -243,7 +239,7 @@ fn process_directory( /// Process a directory on Unix using nix for efficiency #[cfg(unix)] -fn process_directory_unix(file: File, path_read: PathRead) -> anyhow::Result { +fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result { use std::os::fd::AsFd; if !path_read.read_dir_entries { @@ -263,13 +259,16 @@ fn process_directory_unix(file: File, path_read: PathRead) -> anyhow::Result DirEntryKind::File, Some(nix::dir::Type::Directory) => DirEntryKind::Dir, Some(nix::dir::Type::Symlink) => DirEntryKind::Symlink, - // Treat other types as files for fingerprinting + // Treat files and other types as files for fingerprinting _ => DirEntryKind::File, }; + #[expect( + clippy::disallowed_types, + reason = "from_utf8_lossy returns Cow referencing String" + )] let name_str = String::from_utf8_lossy(name); entries.insert(Str::from(name_str.as_ref()), kind); } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 494ec5b7..e9abd806 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -26,7 +26,7 @@ use super::{ use crate::{Session, session::execute::spawn::SpawnTrackResult}; /// Internal error type used to abort execution when errors occur. -/// This error is swallowed in Session::execute and never exposed externally. +/// This error is swallowed in `Session::execute` and never exposed externally. #[derive(Debug)] struct ExecutionAborted; @@ -39,6 +39,7 @@ struct ExecutionContext<'a> { } impl ExecutionContext<'_> { + #[expect(clippy::future_not_send, reason = "uses !Send types internally")] async fn execute_item_kind( &mut self, display: Option<&ExecutionItemDisplay>, @@ -84,7 +85,9 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Error { - message: format!("Cycle dependencies detected: {cycle:?}"), + message: vite_str::format!( + "Cycle dependencies detected: {cycle:?}" + ), }, }); @@ -95,7 +98,7 @@ impl ExecutionContext<'_> { let ordered_executions = node_indices.into_iter().map(|id| graph.remove_node(id).unwrap()); for task_execution in ordered_executions { - for item in task_execution.items.iter() { + for item in &task_execution.items { match &item.kind { ExecutionItemKind::Leaf(leaf_kind) => { self.execute_leaf(Some(&item.execution_item_display), leaf_kind) @@ -115,12 +118,17 @@ impl ExecutionContext<'_> { } } ExecutionItemKind::Leaf(leaf_execution_kind) => { + #[expect( + clippy::large_futures, + reason = "recursive execution creates large futures" + )] self.execute_leaf(display, leaf_execution_kind).await?; } } Ok(()) } + #[expect(clippy::future_not_send, reason = "uses !Send types internally")] async fn execute_leaf( &mut self, display: Option<&ExecutionItemDisplay>, @@ -144,7 +152,7 @@ impl ExecutionContext<'_> { }); // Execute the in-process command - let execution_output = in_process_execution.execute().await; + let execution_output = in_process_execution.execute(); self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Output { @@ -165,12 +173,21 @@ impl ExecutionContext<'_> { }); } LeafExecutionKind::Spawn(spawn_execution) => { + #[expect( + clippy::large_futures, + reason = "spawn execution with cache management creates large futures" + )] self.execute_spawn(execution_id, display, spawn_execution).await?; } } Ok(()) } + #[expect(clippy::future_not_send, reason = "uses !Send types internally")] + #[expect( + clippy::too_many_lines, + reason = "sequential cache check, execute, and update steps are clearer in one function" + )] async fn execute_spawn( &mut self, execution_id: ExecutionId, @@ -183,7 +200,7 @@ impl ExecutionContext<'_> { // We need to know the status before emitting Start event so users // see cache status immediately when execution begins let (cache_status, cached_value) = if let Some(cache_metadata) = cache_metadata { - match self.cache.try_hit(cache_metadata, &*self.cache_base_path).await { + match self.cache.try_hit(cache_metadata, self.cache_base_path).await { Ok(Ok(cached)) => ( // Cache hit - we can replay the cached outputs CacheStatus::Hit { replayed_duration: cached.duration }, @@ -199,7 +216,7 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Error { - message: format!("Cache lookup failed: {err}"), + message: vite_str::format!("Cache lookup failed: {err}"), }, }); return Err(ExecutionAborted); @@ -248,16 +265,17 @@ impl ExecutionContext<'_> { // 4. Execute spawn (cache miss or disabled) // Track file system access if caching is enabled (for future cache updates) - let mut track_result_with_cache_metadata = if let Some(cache_metadata) = cache_metadata { - Some((SpawnTrackResult::default(), cache_metadata)) - } else { - None - }; + let mut track_result_with_cache_metadata = + cache_metadata.map(|cache_metadata| (SpawnTrackResult::default(), cache_metadata)); // Execute command with tracking, emitting output events in real-time + #[expect( + clippy::large_futures, + reason = "spawn_with_tracking manages process I/O and creates a large future" + )] let result = match spawn_with_tracking( &spawn_execution.spawn_command, - &*self.cache_base_path, + self.cache_base_path, |kind, content| { self.event_handler.handle_event(ExecutionEvent { execution_id, @@ -279,7 +297,7 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Error { - message: format!("Failed to spawn process: {err}"), + message: vite_str::format!("Failed to spawn process: {err}"), }, }); return Err(ExecutionAborted); @@ -292,24 +310,26 @@ impl ExecutionContext<'_> { { if result.exit_status.success() { // Execution succeeded, attempt cache update - let fingerprint_ignores = - cache_metadata.spawn_fingerprint.fingerprint_ignores().map(|v| v.as_slice()); + let fingerprint_ignores = cache_metadata + .spawn_fingerprint + .fingerprint_ignores() + .map(std::vec::Vec::as_slice); match PostRunFingerprint::create( &track_result.path_reads, - &*self.cache_base_path, + self.cache_base_path, fingerprint_ignores, ) { Ok(post_run_fingerprint) => { - let cache_value = CommandCacheValue { + let new_cache_value = CommandCacheValue { post_run_fingerprint, std_outputs: track_result.std_outputs.clone().into(), duration: result.duration, }; - if let Err(err) = self.cache.update(cache_metadata, cache_value).await { + if let Err(err) = self.cache.update(cache_metadata, new_cache_value).await { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Error { - message: format!("Failed to update cache: {err}"), + message: vite_str::format!("Failed to update cache: {err}"), }, }); return Err(ExecutionAborted); @@ -320,7 +340,9 @@ impl ExecutionContext<'_> { self.event_handler.handle_event(ExecutionEvent { execution_id, kind: ExecutionEventKind::Error { - message: format!("Failed to create post-run fingerprint: {err}"), + message: vite_str::format!( + "Failed to create post-run fingerprint: {err}" + ), }, }); return Err(ExecutionAborted); @@ -348,13 +370,14 @@ impl ExecutionContext<'_> { } } -impl<'a> Session<'a> { +impl Session<'_> { /// Execute an execution plan, reporting events to the provided reporter. /// /// Returns Err(ExitStatus) to suggest the caller to abort and exit the process with the given exit status. /// - /// The return type isn't just ExitStatus because we want to distinguish between normal successful execution, + /// The return type isn't just `ExitStatus` because we want to distinguish between normal successful execution, /// and execution that failed and needs to exit with a specific code which can be zero. + #[expect(clippy::future_not_send, reason = "uses !Send types internally")] pub(crate) async fn execute( &self, plan: ExecutionPlan, @@ -367,7 +390,7 @@ impl<'a> Session<'a> { reporter.handle_event(ExecutionEvent { execution_id: ExecutionId::zero(), kind: ExecutionEventKind::Error { - message: format!("Failed to initialize cache: {err}"), + message: vite_str::format!("Failed to initialize cache: {err}"), }, }); return Err(ExitStatus(1)); @@ -383,6 +406,10 @@ impl<'a> Session<'a> { // Execute and swallow ExecutionAborted error // display is None for top-level execution + #[expect( + clippy::large_futures, + reason = "top-level execution dispatches the entire task graph" + )] let _ = execution_context.execute_item_kind(None, plan.root_node()).await; // Always call post_execution, whether execution succeeded or failed diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 6cf24019..836de6db 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -9,6 +9,7 @@ use std::{ use bincode::{Decode, Encode}; use bstr::BString; use fspy::AccessMode; +use rustc_hash::FxHashSet; use serde::Serialize; use tokio::io::AsyncReadExt as _; use vite_path::{AbsolutePath, RelativePathBuf}; @@ -22,10 +23,6 @@ pub struct PathRead { pub read_dir_entries: bool, } -/// Path write access info -#[derive(Debug, Clone, Copy)] -pub struct PathWrite; - /// Output kind for stdout/stderr #[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, Serialize)] pub enum OutputKind { @@ -57,7 +54,7 @@ pub struct SpawnTrackResult { pub path_reads: HashMap, /// Tracked path writes - pub path_writes: HashMap, + pub path_writes: FxHashSet, } /// Spawn a command with file system tracking via fspy. @@ -67,6 +64,10 @@ pub struct SpawnTrackResult { /// /// - `on_output` is called in real-time as stdout/stderr data arrives. /// - `track_result` if provided, will be populated with captured outputs and path accesses for caching. If `None`, tracking is disabled. +#[expect( + clippy::too_many_lines, + reason = "spawn logic is inherently sequential and splitting would reduce clarity" +)] pub async fn spawn_with_tracking( spawn_command: &SpawnCommand, workspace_root: &AbsolutePath, @@ -76,12 +77,6 @@ pub async fn spawn_with_tracking( where F: FnMut(OutputKind, BString), { - let mut cmd = fspy::Command::new(spawn_command.program_path.as_path()); - cmd.args(spawn_command.args.iter().map(|arg| arg.as_str())); - cmd.envs(spawn_command.all_envs.iter()); - cmd.current_dir(&*spawn_command.cwd); - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - /// The tracking state of the spawned process enum TrackingState<'a> { /// Tacking is enabled, with the tracked child and result reference @@ -91,6 +86,12 @@ where Disabled(tokio::process::Child), } + let mut cmd = fspy::Command::new(spawn_command.program_path.as_path()); + cmd.args(spawn_command.args.iter().map(vite_str::Str::as_str)); + cmd.envs(spawn_command.all_envs.iter()); + cmd.current_dir(&*spawn_command.cwd); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut tracking_state = if let Some(track_result) = track_result { // track_result is Some. Spawn with tracking enabled TrackingState::Enabled(cmd.spawn().await?, track_result) @@ -198,7 +199,7 @@ where path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); } if access.mode.contains(AccessMode::WRITE) { - path_writes.insert(relative_path.clone(), PathWrite); + path_writes.insert(relative_path.clone()); } if access.mode.contains(AccessMode::READ_DIR) { match path_reads.entry(relative_path) { diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index a0de13a0..48badea9 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -12,6 +12,7 @@ pub use event::ExecutionEvent; use once_cell::sync::OnceCell; pub use reporter::ExitStatus; use reporter::LabeledReporter; +use rustc_hash::FxHashMap; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; use vite_task_graph::{ @@ -25,10 +26,7 @@ use vite_task_plan::{ }; use vite_workspace::{WorkspaceRoot, find_workspace_root}; -use crate::{ - cli::{CacheSubcommand, Command, RunCommand}, - collections::HashMap, -}; +use crate::cli::{CacheSubcommand, Command, RunCommand}; #[derive(Debug)] enum LazyTaskGraph<'a> { @@ -47,7 +45,7 @@ impl TaskGraphLoader for LazyTaskGraph<'_> { *self = Self::Initialized(graph); match self { Self::Initialized(graph) => &*graph, - _ => unreachable!(), + Self::Uninitialized { .. } => unreachable!(), } } Self::Initialized(graph) => &*graph, @@ -122,27 +120,33 @@ pub struct Session<'a> { /// The task graph is loaded on-demand and cached for future use. lazy_task_graph: LazyTaskGraph<'a>, - envs: Arc, Arc>>, + envs: Arc, Arc>>, cwd: Arc, plan_request_parser: PlanRequestParser<'a>, - /// Cache is lazily initialized to avoid SQLite race conditions when multiple + /// Cache is lazily initialized to avoid `SQLite` race conditions when multiple /// processes (e.g., parallel `vp lib` commands) start simultaneously. cache: OnceCell, cache_path: AbsolutePathBuf, } fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf { - if let Ok(env_cache_path) = std::env::var("VITE_CACHE_PATH") { - AbsolutePathBuf::new(env_cache_path.into()).expect("Cache path should be absolute") - } else { - workspace_root.join("node_modules/.vite/task-cache") - } + std::env::var("VITE_CACHE_PATH").map_or_else( + |_| workspace_root.join("node_modules/.vite/task-cache"), + |env_cache_path| { + AbsolutePathBuf::new(env_cache_path.into()).expect("Cache path should be absolute") + }, + ) } impl<'a> Session<'a> { /// Initialize a session with real environment variables and cwd + /// + /// # Errors + /// + /// Returns an error if the current directory cannot be determined or + /// if workspace initialization fails. pub fn init(callbacks: SessionCallbacks<'a>) -> anyhow::Result { let envs = std::env::vars_os() .map(|(k, v)| (Arc::::from(k.as_os_str()), Arc::::from(v.as_os_str()))) @@ -150,6 +154,15 @@ impl<'a> Session<'a> { Self::init_with(envs, vite_path::current_dir()?.into(), callbacks) } + /// Ensures the task graph is loaded, loading it if necessary. + /// + /// # Errors + /// + /// Returns an error if the task graph cannot be loaded from the workspace configuration. + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] pub async fn ensure_task_graph_loaded( &mut self, ) -> Result<&IndexedTaskGraph, TaskGraphLoadError> { @@ -157,8 +170,16 @@ impl<'a> Session<'a> { } /// Initialize a session with custom cwd, environment variables. Useful for testing. + /// + /// # Errors + /// + /// Returns an error if workspace root cannot be found or PATH env cannot be prepended. + #[expect( + clippy::needless_pass_by_value, + reason = "cwd is an Arc that gets cloned internally, pass by value is intentional" + )] pub fn init_with( - mut envs: HashMap, Arc>, + mut envs: FxHashMap, Arc>, cwd: Arc, callbacks: SessionCallbacks<'a>, ) -> anyhow::Result { @@ -185,9 +206,21 @@ impl<'a> Session<'a> { } /// Primary entry point for CLI usage. Plans and executes the given command. + /// + /// # Errors + /// + /// Returns an error if planning or execution fails. + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + #[expect( + clippy::large_futures, + reason = "execution plan future is large but only awaited once" + )] pub async fn main(mut self, command: Command) -> anyhow::Result { match command { - Command::Cache { subcmd } => self.handle_cache_command(subcmd), + Command::Cache { ref subcmd } => self.handle_cache_command(subcmd), Command::Run(run_command) => { let cwd = Arc::clone(&self.cwd); let plan = self.plan_from_cli(cwd, run_command).await?; @@ -201,7 +234,7 @@ impl<'a> Session<'a> { } } - fn handle_cache_command(&self, subcmd: CacheSubcommand) -> anyhow::Result { + fn handle_cache_command(&self, subcmd: &CacheSubcommand) -> anyhow::Result { match subcmd { CacheSubcommand::Clean => { if self.cache_path.as_path().exists() { @@ -213,28 +246,32 @@ impl<'a> Session<'a> { } /// Lazily initializes and returns the execution cache. - /// The cache is only created when first accessed to avoid SQLite race conditions + /// The cache is only created when first accessed to avoid `SQLite` race conditions /// when multiple processes start simultaneously. + /// + /// # Errors + /// + /// Returns an error if the cache database cannot be loaded or created. pub fn cache(&self) -> anyhow::Result<&ExecutionCache> { - self.cache.get_or_try_init(|| ExecutionCache::load_from_path(self.cache_path.clone())) + self.cache.get_or_try_init(|| ExecutionCache::load_from_path(&self.cache_path)) } pub fn workspace_path(&self) -> Arc { Arc::clone(&self.workspace_path) } - pub fn task_graph(&self) -> Option<&TaskGraph> { + pub const fn task_graph(&self) -> Option<&TaskGraph> { match &self.lazy_task_graph { LazyTaskGraph::Initialized(graph) => Some(graph.task_graph()), - _ => None, + LazyTaskGraph::Uninitialized { .. } => None, } } - pub fn envs(&self) -> &Arc, Arc>> { + pub const fn envs(&self) -> &Arc, Arc>> { &self.envs } - pub fn cwd(&self) -> &Arc { + pub const fn cwd(&self) -> &Arc { &self.cwd } @@ -242,6 +279,18 @@ impl<'a> Session<'a> { /// /// This is for executing a command with cache before/without the entrypoint [`Session::main`]. /// In vite-plus, this is used for auto-install. + /// + /// # Errors + /// + /// Returns an error if planning or execution of the synthetic command fails. + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] + #[expect( + clippy::large_futures, + reason = "execution plan future is large but only awaited once" + )] pub async fn execute_synthetic( &self, synthetic_plan_request: SyntheticPlanRequest, @@ -260,6 +309,15 @@ impl<'a> Session<'a> { Ok(self.execute(plan, Box::new(reporter)).await.err().unwrap_or(ExitStatus::SUCCESS)) } + /// Plans execution from a CLI run command. + /// + /// # Errors + /// + /// Returns an error if the plan request cannot be parsed or if planning fails. + #[expect( + clippy::future_not_send, + reason = "session is single-threaded, futures do not need to be Send" + )] pub async fn plan_from_cli( &mut self, cwd: Arc, @@ -269,7 +327,7 @@ impl<'a> Session<'a> { TaskPlanErrorKind::ParsePlanRequestError { error: error.into(), program: Str::from("vp"), - args: Default::default(), + args: Arc::default(), cwd: Arc::clone(&cwd), } .with_empty_call_stack() diff --git a/crates/vite_task/src/session/reporter.rs b/crates/vite_task/src/session/reporter.rs index bc74a8bc..3344f26e 100644 --- a/crates/vite_task/src/session/reporter.rs +++ b/crates/vite_task/src/session/reporter.rs @@ -1,7 +1,6 @@ -//! LabeledReporter event handler for rendering execution events. +//! `LabeledReporter` event handler for rendering execution events. use std::{ - collections::HashSet, io::Write, process::ExitStatus as StdExitStatus, sync::{Arc, LazyLock}, @@ -9,7 +8,9 @@ use std::{ }; use owo_colors::{Style, Styled}; +use rustc_hash::FxHashSet; use vite_path::AbsolutePath; +use vite_str::Str; use super::{ cache::{format_cache_status_inline, format_cache_status_summary}, @@ -61,7 +62,7 @@ struct ExecutionInfo { cache_status: CacheStatus, // Non-optional, determined at Start /// Exit status from the process. None means no process was spawned (cache hit or in-process). exit_status: Option, - error_message: Option, + error_message: Option, } /// Statistics for the execution summary @@ -94,14 +95,14 @@ struct ExecutionStats { /// ## Simplified Summary for Single Tasks /// - When a single task is executed: /// - Skips full summary (no Statistics/Task Details sections) -/// - Shows only cache status (except for "NotFound" which is hidden for clean first-run output) +/// - Shows only cache status (except for "`NotFound`" which is hidden for clean first-run output) /// - Results in clean output showing just the command's stdout/stderr pub struct LabeledReporter { writer: W, workspace_path: Arc, executions: Vec, stats: ExecutionStats, - first_error: Option, + first_error: Option, /// When true, suppresses command line and output for cache hit executions silent_if_cache_hit: bool, @@ -109,8 +110,8 @@ pub struct LabeledReporter { /// When true, skips printing the execution summary at the end hide_summary: bool, - /// Tracks which executions are cache hits (for silent_if_cache_hit mode) - cache_hit_executions: HashSet, + /// Tracks which executions are cache hits (for `silent_if_cache_hit` mode) + cache_hit_executions: FxHashSet, } impl LabeledReporter { @@ -123,17 +124,17 @@ impl LabeledReporter { first_error: None, silent_if_cache_hit: false, hide_summary: false, - cache_hit_executions: HashSet::new(), + cache_hit_executions: FxHashSet::default(), } } - /// Set the silent_if_cache_hit option - pub fn set_silent_if_cache_hit(&mut self, silent_if_cache_hit: bool) { + /// Set the `silent_if_cache_hit` option + pub const fn set_silent_if_cache_hit(&mut self, silent_if_cache_hit: bool) { self.silent_if_cache_hit = silent_if_cache_hit; } - /// Set the hide_summary option - pub fn set_hide_summary(&mut self, hide_summary: bool) { + /// Set the `hide_summary` option + pub const fn set_hide_summary(&mut self, hide_summary: bool) { self.hide_summary = hide_summary; } @@ -170,14 +171,17 @@ impl LabeledReporter { // Compute cwd relative to workspace root let cwd_relative = if let Ok(Some(rel)) = display.cwd.strip_prefix(&self.workspace_path) { - rel.as_str().to_string() + Str::from(rel.as_str()) } else { - String::new() + Str::default() }; - let cwd_str = - if cwd_relative.is_empty() { String::new() } else { format!("~/{cwd_relative}") }; - let command_str = format!("{cwd_str}$ {}", display.command); + let cwd_str = if cwd_relative.is_empty() { + Str::default() + } else { + vite_str::format!("~/{cwd_relative}") + }; + let command_str = vite_str::format!("{cwd_str}$ {}", display.command); // Skip printing if silent_if_cache_hit is enabled and this is a cache hit let should_print = @@ -209,7 +213,7 @@ impl LabeledReporter { }); } - fn handle_error(&mut self, _execution_id: ExecutionId, message: String) { + fn handle_error(&mut self, _execution_id: ExecutionId, message: Str) { // Display error inline (in red, with error icon) let _ = writeln!( self.writer, @@ -244,17 +248,18 @@ impl LabeledReporter { } // For direct synthetic execution with cache hit, print message at the bottom - if let Some(exec) = self.executions.last() { - if exec.display.is_none() && matches!(exec.cache_status, CacheStatus::Hit { .. }) { - let should_print = - !self.silent_if_cache_hit || !self.cache_hit_executions.contains(&execution_id); - if should_print { - let _ = writeln!( - self.writer, - "{}", - "✓ cache hit, logs replayed".style(Style::new().green().dimmed()) - ); - } + if let Some(exec) = self.executions.last() + && exec.display.is_none() + && matches!(exec.cache_status, CacheStatus::Hit { .. }) + { + let should_print = + !self.silent_if_cache_hit || !self.cache_hit_executions.contains(&execution_id); + if should_print { + let _ = writeln!( + self.writer, + "{}", + "✓ cache hit, logs replayed".style(Style::new().green().dimmed()) + ); } } @@ -266,6 +271,10 @@ impl LabeledReporter { } /// Print execution summary after all events + #[expect( + clippy::too_many_lines, + reason = "summary formatting is inherently verbose with many write calls" + )] pub fn print_summary(&mut self) { let total = self.executions.len(); let cache_hits = self.stats.cache_hits; @@ -296,17 +305,19 @@ impl LabeledReporter { // Print statistics let cache_disabled_str = if cache_disabled > 0 { - format!("• {cache_disabled} cache disabled") - .style(Style::new().bright_black()) - .to_string() + Str::from( + vite_str::format!("• {cache_disabled} cache disabled") + .style(Style::new().bright_black()) + .to_string(), + ) } else { - String::new() + Str::default() }; let failed_str = if failed > 0 { - format!("• {failed} failed").style(Style::new().red()).to_string() + Str::from(vite_str::format!("• {failed} failed").style(Style::new().red()).to_string()) } else { - String::new() + Str::default() }; // Build statistics line, only including non-empty parts @@ -315,21 +326,32 @@ impl LabeledReporter { self.writer, "{} {} {} {} ", "Statistics:".style(Style::new().bold()), - format!(" {total} tasks").style(Style::new().bright_white()), - format!("• {cache_hits} cache hits").style(Style::new().green()), - format!("• {cache_misses} cache misses").style(CACHE_MISS_STYLE), + vite_str::format!(" {total} tasks").style(Style::new().bright_white()), + vite_str::format!("• {cache_hits} cache hits").style(Style::new().green()), + vite_str::format!("• {cache_misses} cache misses").style(CACHE_MISS_STYLE), ); if !cache_disabled_str.is_empty() { - let _ = write!(self.writer, "{} ", cache_disabled_str); + let _ = write!(self.writer, "{cache_disabled_str} "); } if !failed_str.is_empty() { - let _ = write!(self.writer, "{} ", failed_str); + let _ = write!(self.writer, "{failed_str} "); } let _ = writeln!(self.writer); // Calculate cache hit rate let cache_rate = if total > 0 { - (f64::from(cache_hits as u32) / total as f64 * 100.0) as u32 + #[expect( + clippy::cast_possible_truncation, + reason = "percentage is always 0..=100, fits in u32" + )] + #[expect(clippy::cast_sign_loss, reason = "percentage is always non-negative")] + #[expect( + clippy::cast_precision_loss, + reason = "acceptable precision loss for display percentage" + )] + { + (f64::from(cache_hits as u32) / total as f64 * 100.0) as u32 + } } else { 0 }; @@ -390,20 +412,23 @@ impl LabeledReporter { let _ = write!( self.writer, " {} {}", - format!("[{}]", idx + 1).style(Style::new().bright_black()), + vite_str::format!("[{}]", idx + 1).style(Style::new().bright_black()), task_display.to_string().style(Style::new().bright_white().bold()) ); // Command with cwd prefix let cwd_relative = if let Ok(Some(rel)) = display.cwd.strip_prefix(&self.workspace_path) { - rel.as_str().to_string() + Str::from(rel.as_str()) + } else { + Str::default() + }; + let cwd_str = if cwd_relative.is_empty() { + Str::default() } else { - String::new() + vite_str::format!("~/{cwd_relative}") }; - let cwd_str = - if cwd_relative.is_empty() { String::new() } else { format!("~/{cwd_relative}") }; - let command_display = format!("{cwd_str}$ {}", display.command); + let command_display = vite_str::format!("{cwd_str}$ {}", display.command); let _ = write!(self.writer, ": {}", command_display.style(COMMAND_STYLE)); // Execution result icon @@ -416,12 +441,12 @@ impl LabeledReporter { let _ = write!(self.writer, " {}", "✓".style(Style::new().green().bold())); } Some(status) => { - let code = exit_status_to_code(status); + let code = exit_status_to_code(*status); let _ = write!( self.writer, " {} {}", "✗".style(Style::new().red().bold()), - format!("(exit code: {code})").style(Style::new().red()) + vite_str::format!("(exit code: {code})").style(Style::new().red()) ); } } @@ -434,7 +459,7 @@ impl LabeledReporter { CacheStatus::Miss(_) => cache_summary.style(CACHE_MISS_STYLE), CacheStatus::Disabled(_) => cache_summary.style(Style::new().bright_black()), }; - let _ = writeln!(self.writer, " {}", styled_summary); + let _ = writeln!(self.writer, " {styled_summary}"); // Error message if present if let Some(ref error_msg) = exec.error_message { @@ -466,9 +491,13 @@ impl LabeledReporter { /// Print simplified cache status for single built-in commands /// - /// Note: Inline cache status is now printed at Start event in handle_start(), + /// Note: Inline cache status is now printed at Start event in `handle_start()`, /// so this function is a no-op to avoid duplicate output. - fn print_simple_cache_status(&mut self) { + #[expect( + clippy::unused_self, + reason = "method signature kept for API consistency with print_summary" + )] + const fn print_simple_cache_status(&self) { // Inline cache status already printed at Start event - nothing to do here } } @@ -548,13 +577,17 @@ impl Reporter for LabeledReporter { .iter() .filter_map(|exec| exec.exit_status.as_ref()) .filter(|status| !status.success()) - .map(exit_status_to_code) + .map(|status| exit_status_to_code(*status)) .collect(); match failed_exit_codes.as_slice() { [] => Ok(()), [code] => { // Return the single failed task's exit code (clamped to u8 range) + #[expect( + clippy::cast_sign_loss, + reason = "value is clamped to 1..=255, always positive" + )] Err(ExitStatus((*code).clamp(1, 255) as u8)) } _ => Err(ExitStatus::FAILURE), diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index c6bdf013..5a12befb 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } jsonc-parser = { workspace = true } +rustc-hash = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } vite_path = { workspace = true } diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index 321a8d40..392a6df4 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -1,13 +1,12 @@ use std::{ - collections::HashMap, env::{self, join_paths}, ffi::OsStr, iter, - path::PathBuf, sync::Arc, }; use clap::Parser; +use rustc_hash::FxHashMap; use vite_path::AbsolutePath; use vite_str::Str; use vite_task::{ @@ -18,13 +17,22 @@ use vite_task::{ #[derive(Debug, Default)] pub struct CommandHandler(()); +/// Find an executable in `node_modules/.bin` directories up the tree. +/// +/// # Errors +/// +/// Returns an error if the executable cannot be found in any searched path. pub fn find_executable( path_env: Option<&Arc>, cwd: &AbsolutePath, executable: &str, ) -> anyhow::Result> { - let mut paths: Vec = - if let Some(path_env) = path_env { env::split_paths(path_env).collect() } else { vec![] }; + #[expect( + clippy::disallowed_types, + reason = "PathBuf required by env::split_paths and which::which_in APIs" + )] + let mut paths: Vec = + path_env.map_or_else(Vec::new, |path_env| env::split_paths(path_env).collect()); let mut current_cwd_parent = cwd; loop { let node_modules_bin = current_cwd_parent.join("node_modules").join(".bin"); @@ -39,14 +47,19 @@ pub fn find_executable( Ok(executable_path.into_os_string().into()) } +/// Create a synthetic plan request for running a tool from `node_modules/.bin`. +/// +/// # Errors +/// +/// Returns an error if the executable cannot be found. fn synthesize_node_modules_bin_task( executable_name: &str, args: &[Str], - envs: &Arc, Arc>>, + envs: &Arc, Arc>>, cwd: &Arc, ) -> anyhow::Result { Ok(SyntheticPlanRequest { - program: find_executable(get_path_env(envs), &*cwd, executable_name)?, + program: find_executable(get_path_env(envs), cwd, executable_name)?, args: args.into(), cache_config: UserCacheConfig::with_config(EnabledCacheConfig { envs: None, @@ -102,14 +115,14 @@ impl vite_task::CommandHandler for CommandHandler { synthesize_node_modules_bin_task("vitest", &args, &command.envs, &command.cwd)?, )), Args::EnvTest { name, value } => { - let mut envs = HashMap::clone(&command.envs); + let mut envs = FxHashMap::clone(&command.envs); envs.insert( Arc::from(OsStr::new(name.as_str())), Arc::from(OsStr::new(value.as_str())), ); Ok(HandledCommand::Synthesized(SyntheticPlanRequest { - program: find_executable(get_path_env(&envs), &*command.cwd, "print-env")?, + program: find_executable(get_path_env(&envs), &command.cwd, "print-env")?, args: [name.clone()].into(), cache_config: UserCacheConfig::with_config({ EnabledCacheConfig { envs: None, pass_through_envs: Some(vec![name]) } @@ -142,8 +155,11 @@ impl vite_task::loader::UserConfigLoader for JsonUserConfigLoader { } Err(err) => return Err(err.into()), }; - let json_value = jsonc_parser::parse_to_serde_value(&config_content, &Default::default())? - .unwrap_or_default(); + let json_value = jsonc_parser::parse_to_serde_value( + &config_content, + &jsonc_parser::ParseOptions::default(), + )? + .unwrap_or_default(); let user_config: vite_task::config::UserRunConfig = serde_json::from_value(json_value)?; Ok(Some(user_config)) } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index a7120ebe..4b9a5250 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -10,16 +10,23 @@ use vite_task_bin::{Args, OwnedSessionCallbacks, find_executable}; #[tokio::main] async fn main() -> anyhow::Result { + #[expect(clippy::large_futures, reason = "top-level await in main, no alternative")] let exit_status = run().await?; Ok(exit_status.0.into()) } +#[expect(clippy::future_not_send, reason = "Session contains !Send types; single-threaded runtime")] async fn run() -> anyhow::Result { let args = Args::parse(); let mut owned_callbacks = OwnedSessionCallbacks::default(); let session = Session::init(owned_callbacks.as_callbacks())?; match args { - Args::Task(command) => session.main(command).await, + Args::Task(command) => { + #[expect(clippy::large_futures, reason = "session.main produces a large future")] + { + session.main(command).await + } + } args => { // If env FOO is set, run `print-env FOO` via Session::exec before proceeding. // In vite-plus, Session::exec is used for auto-install. @@ -38,12 +45,19 @@ async fn run() -> anyhow::Result { envs: Arc::clone(envs), }; let cache_key: Arc<[Str]> = Arc::from([Str::from("print-env-foo")]); + #[expect( + clippy::large_futures, + reason = "execute_synthetic produces a large future" + )] let status = session.execute_synthetic(request, cache_key, true).await?; if status != ExitStatus::SUCCESS { return Ok(status); } } - println!("{:?}", args); + #[expect(clippy::print_stdout, reason = "CLI binary output for non-task commands")] + { + println!("{args:?}"); + } Ok(ExitStatus::SUCCESS) } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 3604c2a7..c8436201 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -3,7 +3,6 @@ mod redact; use std::{ env::{self, join_paths, split_paths}, ffi::OsStr, - path::{Path, PathBuf}, process::Stdio, sync::Arc, time::Duration, @@ -25,25 +24,30 @@ const STEP_TIMEOUT: Duration = Duration::from_secs(10); /// Get the shell executable for running e2e test steps. /// On Unix, uses /bin/sh. /// On Windows, uses BASH env var or falls back to Git Bash. -fn get_shell_exe() -> PathBuf { +#[expect( + clippy::disallowed_types, + reason = "PathBuf required for Command::new and std::path operations on shell executable" +)] +fn get_shell_exe() -> std::path::PathBuf { if cfg!(windows) { - if let Some(bash) = std::env::var_os("BASH") { - PathBuf::from(bash) - } else { - let git_bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"); - if git_bash.exists() { - git_bash - } else { - panic!( - "Could not find bash executable for e2e tests.\n\ - Please set the BASH environment variable to point to a bash executable,\n\ - or install Git for Windows which provides bash at:\n\ - C:\\Program Files\\Git\\bin\\bash.exe" - ); - } - } + std::env::var_os("BASH").map_or_else( + || { + let git_bash = std::path::PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"); + if git_bash.exists() { + git_bash + } else { + panic!( + "Could not find bash executable for e2e tests.\n\ + Please set the BASH environment variable to point to a bash executable,\n\ + or install Git for Windows which provides bash at:\n\ + C:\\Program Files\\Git\\bin\\bash.exe" + ); + } + }, + std::path::PathBuf::from, + ) } else { - PathBuf::from("/bin/sh") + std::path::PathBuf::from("/bin/sh") } } @@ -57,15 +61,15 @@ enum Step { impl Step { fn cmd(&self) -> &str { match self { - Step::Simple(s) => s.as_str(), - Step::WithStdin { cmd, .. } => cmd.as_str(), + Self::Simple(s) => s.as_str(), + Self::WithStdin { cmd, .. } => cmd.as_str(), } } fn stdin(&self) -> Option<&str> { match self { - Step::Simple(_) => None, - Step::WithStdin { stdin, .. } => Some(stdin.as_str()), + Self::Simple(_) => None, + Self::WithStdin { stdin, .. } => Some(stdin.as_str()), } } } @@ -87,24 +91,28 @@ struct SnapshotsFile { pub e2e_cases: Vec, } +#[expect(clippy::disallowed_types, reason = "Path required by insta::glob! callback signature")] fn run_case( runtime: &tokio::runtime::Runtime, tmpdir: &AbsolutePath, - fixture_path: &Path, + fixture_path: &std::path::Path, filter: Option<&str>, ) { let fixture_name = fixture_path.file_name().unwrap().to_str().unwrap(); - if fixture_name.starts_with(".") { + if fixture_name.starts_with('.') { return; // skip hidden files like .DS_Store } // Skip if filter doesn't match - if let Some(f) = filter { - if !fixture_name.contains(f) { - return; - } + if let Some(f) = filter + && !fixture_name.contains(f) + { + return; + } + #[expect(clippy::print_stdout, reason = "test progress output for e2e test runner")] + { + println!("{fixture_name}"); } - println!("{}", fixture_name); // Configure insta to write snapshots to fixture directory let mut settings = insta::Settings::clone_current(); settings.set_snapshot_path(fixture_path.join("snapshots")); @@ -115,7 +123,20 @@ fn run_case( settings.bind(|| runtime.block_on(run_case_inner(tmpdir, fixture_path, fixture_name))); } -async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name: &str) { +enum TerminationState { + Exited(std::process::ExitStatus), + TimedOut, +} + +#[expect( + clippy::too_many_lines, + reason = "e2e test runner with process management necessarily has many lines" +)] +#[expect( + clippy::disallowed_types, + reason = "Path required by insta::glob! callback; String required by from_utf8_lossy and string accumulation" +)] +async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture_name: &str) { // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. let stage_path = tmpdir.join(fixture_name); copy_dir(fixture_path, &stage_path).unwrap(); @@ -124,18 +145,21 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name assert_eq!( &stage_path, &*workspace_root.path, - "folder '{}' should be a workspace root", - fixture_name + "folder '{fixture_name}' should be a workspace root" ); let cases_toml_path = fixture_path.join("snapshots.toml"); let cases_file: SnapshotsFile = match std::fs::read(&cases_toml_path) { Ok(content) => toml::from_slice(&content).unwrap(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Default::default(), - Err(err) => panic!("Failed to read cases.toml for fixture {}: {}", fixture_name, err), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => SnapshotsFile::default(), + Err(err) => panic!("Failed to read cases.toml for fixture {fixture_name}: {err}"), }; // Navigate from CARGO_MANIFEST_DIR to packages/tools at the repo root + #[expect( + clippy::disallowed_types, + reason = "Path required for CARGO_MANIFEST_DIR path manipulation via env! macro" + )] let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); let test_bin_path = Arc::::from( @@ -180,7 +204,7 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name } } - let e2e_stage_path = tmpdir.join(format!("{}_e2e_stage_{}", fixture_name, e2e_count)); + let e2e_stage_path = tmpdir.join(vite_str::format!("{fixture_name}_e2e_stage_{e2e_count}")); e2e_count += 1; assert!(copy_dir(fixture_path, &e2e_stage_path).unwrap().is_empty()); @@ -197,10 +221,10 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name .current_dir(e2e_stage_path.join(&e2e.cwd)); // On Windows, inherit PATHEXT for executable lookup - if cfg!(windows) { - if let Ok(pathext) = std::env::var("PATHEXT") { - cmd.env("PATHEXT", pathext); - } + if cfg!(windows) + && let Ok(pathext) = std::env::var("PATHEXT") + { + cmd.env("PATHEXT", pathext); } // Spawn the child process @@ -229,10 +253,6 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name let mut stdout_done = false; let mut stderr_done = false; - enum TerminationState { - Exited(std::process::ExitStatus), - TimedOut, - } // Initial state is running let mut termination_state: Option = None; @@ -246,22 +266,20 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name tokio::select! { result = stdout_handle.read(&mut stdout_chunk), if !stdout_done => { match result { - Ok(0) => stdout_done = true, + Ok(0) | Err(_) => stdout_done = true, Ok(n) => stdout_buf.extend_from_slice(&stdout_chunk[..n]), - Err(_) => stdout_done = true, } } result = stderr_handle.read(&mut stderr_chunk), if !stderr_done => { match result { - Ok(0) => stderr_done = true, + Ok(0) | Err(_) => stderr_done = true, Ok(n) => stderr_buf.extend_from_slice(&stderr_chunk[..n]), - Err(_) => stderr_done = true, } } result = child.wait(), if termination_state.is_none() => { termination_state = Some(TerminationState::Exited(result.unwrap())); } - _ = &mut timeout, if termination_state.is_none() => { + () = &mut timeout, if termination_state.is_none() => { // Timeout - kill the process let _ = child.kill().await; termination_state = Some(TerminationState::TimedOut); @@ -287,7 +305,7 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name TerminationState::Exited(status) => { let exit_code = status.code().unwrap_or(-1); if exit_code != 0 { - e2e_outputs.push_str(format!("[{}]", exit_code).as_str()); + e2e_outputs.push_str(vite_str::format!("[{exit_code}]").as_str()); } } } @@ -307,10 +325,21 @@ async fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &Path, fixture_name break; } } - insta::assert_snapshot!(e2e.name.as_str(), e2e_outputs); + #[expect( + clippy::disallowed_macros, + reason = "insta::assert_snapshot! internally uses std::format!" + )] + { + insta::assert_snapshot!(e2e.name.as_str(), e2e_outputs); + } } } +#[expect(clippy::disallowed_types, reason = "Path required by insta::glob! macro callback")] +#[expect( + clippy::disallowed_methods, + reason = "current_dir needed because insta::glob! requires std PathBuf" +)] fn main() { let filter = std::env::args().nth(1); @@ -323,6 +352,6 @@ fn main() { let runtime = tokio::runtime::Runtime::new().unwrap(); insta::glob!(tests_dir, "e2e_snapshots/fixtures/*", |case_path| { - run_case(&runtime, &tmp_dir_path, case_path, filter.as_deref()) + run_case(&runtime, &tmp_dir_path, case_path, filter.as_deref()); }); } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs index 0e6d1de5..6467f66a 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs @@ -1,5 +1,9 @@ use std::borrow::Cow; +#[expect( + clippy::disallowed_types, + reason = "String mutation required by regex replace and cow_replace APIs" +)] fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { use cow_utils::CowUtils as _; for (from, to) in redactions { @@ -13,6 +17,10 @@ fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { } } +#[expect( + clippy::disallowed_types, + reason = "String required by regex replace_all and cow_replace APIs; Path required for CARGO_MANIFEST_DIR path manipulation" +)] pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); // Get the packages/tools directory path @@ -55,12 +63,16 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { // Sort consecutive diagnostic blocks to handle non-deterministic tool output // (e.g., oxlint reports warnings in arbitrary order due to multi-threading). // Each block starts with " ! " and ends at the next empty line. - output = sort_diagnostic_blocks(output); + output = sort_diagnostic_blocks(&output); output } -fn sort_diagnostic_blocks(output: String) -> String { +#[expect( + clippy::disallowed_types, + reason = "String return required because join produces a String" +)] +fn sort_diagnostic_blocks(output: &str) -> String { let parts: Vec<&str> = output.split('\n').collect(); let mut result: Vec<&str> = Vec::new(); let mut i = 0; diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 36b32dc8..90c4c05c 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -12,6 +12,7 @@ async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } monostate = { workspace = true } petgraph = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 25ec382c..cf7b4103 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -1,8 +1,9 @@ pub mod user; -use std::{collections::HashSet, sync::Arc}; +use std::sync::Arc; use monostate::MustBe; +use rustc_hash::FxHashSet; use serde::Serialize; pub use user::{EnabledCacheConfig, UserCacheConfig, UserRunConfig, UserTaskConfig}; use vite_path::AbsolutePath; @@ -76,7 +77,7 @@ pub struct CacheConfig { #[derive(Debug, Serialize)] pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated - pub fingerprinted_envs: HashSet, + pub fingerprinted_envs: FxHashSet, /// environment variable names to be passed to the task without fingerprinting, with defaults populated pub pass_through_envs: Arc<[Str]>, } @@ -98,6 +99,7 @@ impl ResolvedTaskConfig { /// The `cache_scripts` parameter determines whether caching is enabled for the script. /// When `true`, caching is enabled with default settings. /// When `false`, caching is disabled. + #[must_use] pub fn resolve_package_json_script( package_dir: &Arc, package_json_script: &str, @@ -119,6 +121,11 @@ impl ResolvedTaskConfig { } /// Resolves from user config, package dir, and package.json script (if any). + /// + /// # Errors + /// + /// Returns [`ResolveTaskConfigError::CommandConflict`] if both the user config and + /// package.json define a command, or [`ResolveTaskConfigError::NoCommand`] if neither does. pub fn resolve( user_config: UserTaskConfig, package_dir: &Arc, diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index c4e0ac84..0607ed98 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -1,23 +1,25 @@ //! Configuration structures for user-defined tasks in `vite.config.*` -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use monostate::MustBe; +use rustc_hash::FxHashMap; use serde::Deserialize; -#[cfg(test)] +#[cfg(all(test, not(clippy)))] use ts_rs::TS; use vite_path::RelativePathBuf; use vite_str::Str; /// Cache-related fields of a task defined by user in `vite.config.*` #[derive(Debug, Deserialize, PartialEq, Eq)] -#[cfg_attr(test, derive(TS), ts(optional_fields))] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields))] #[serde(untagged, deny_unknown_fields, rename_all = "camelCase")] pub enum UserCacheConfig { /// Cache is enabled Enabled { /// Whether to cache the task - #[cfg_attr(test, ts(type = "true", optional))] + #[cfg_attr(all(test, not(clippy)), ts(type = "true", optional))] cache: Option, #[serde(flatten)] @@ -26,26 +28,29 @@ pub enum UserCacheConfig { /// Cache is disabled Disabled { /// Whether to cache the task - #[cfg_attr(test, ts(type = "false"))] + #[cfg_attr(all(test, not(clippy)), ts(type = "false"))] cache: MustBe!(false), }, } impl UserCacheConfig { /// Create an enabled cache config with the given `EnabledCacheConfig`. - pub fn with_config(config: EnabledCacheConfig) -> Self { - UserCacheConfig::Enabled { cache: Some(MustBe!(true)), enabled_cache_config: config } + #[must_use] + pub const fn with_config(config: EnabledCacheConfig) -> Self { + Self::Enabled { cache: Some(MustBe!(true)), enabled_cache_config: config } } /// Create a disabled cache config. - pub fn disabled() -> Self { - UserCacheConfig::Disabled { cache: MustBe!(false) } + #[must_use] + pub const fn disabled() -> Self { + Self::Disabled { cache: MustBe!(false) } } } /// Cache configuration fields when caching is enabled #[derive(Debug, Deserialize, PartialEq, Eq)] -#[cfg_attr(test, derive(TS), ts(optional_fields))] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields))] #[serde(rename_all = "camelCase")] pub struct EnabledCacheConfig { /// Environment variable names to be fingerprinted and passed to the task. @@ -57,7 +62,8 @@ pub struct EnabledCacheConfig { /// Options for user-defined tasks in `vite.config.*`, excluding the command. #[derive(Debug, Deserialize, PartialEq, Eq)] -#[cfg_attr(test, derive(TS), ts(optional_fields))] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields))] #[serde(rename_all = "camelCase")] pub struct UserTaskOptions { /// The working directory for the task, relative to the package root (not workspace root). @@ -91,7 +97,8 @@ impl Default for UserTaskOptions { /// Full user-defined task configuration in `vite.config.*`, including the command and options. #[derive(Debug, Deserialize, PartialEq, Eq)] -#[cfg_attr(test, derive(TS), ts(optional_fields, rename = "Task"))] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "Task"))] #[serde(rename_all = "camelCase")] pub struct UserTaskConfig { /// The command to run for the task. @@ -106,7 +113,8 @@ pub struct UserTaskConfig { /// User configuration structure for `run` field in `vite.config.*` #[derive(Debug, Default, Deserialize)] -#[cfg_attr(test, derive(TS), ts(optional_fields, rename = "RunConfig"))] +// TS derive macro generates code using std types that clippy disallows; skip derive during linting +#[cfg_attr(all(test, not(clippy)), derive(TS), ts(optional_fields, rename = "RunConfig"))] #[serde(rename_all = "camelCase")] pub struct UserRunConfig { /// Enable cache for all scripts from package.json. @@ -116,7 +124,7 @@ pub struct UserRunConfig { pub cache_scripts: Option, /// Task definitions - pub tasks: Option>, + pub tasks: Option>, } impl UserRunConfig { @@ -124,7 +132,15 @@ impl UserRunConfig { pub const TS_TYPE: &str = include_str!("../../run-config.ts"); /// Generates TypeScript type definitions for user task configuration. - #[cfg(test)] + /// + /// # Panics + /// + /// Panics if `oxfmt` is not found in `packages/tools`, if the formatter process + /// fails to spawn or write, or if the output is not valid UTF-8. + #[cfg(all(test, not(clippy)))] + #[must_use] + // test code: uses std types for convenience + #[expect(clippy::disallowed_types, reason = "test code uses std types for convenience")] pub fn generate_ts_definition() -> String { use std::{ io::Write, @@ -189,17 +205,25 @@ impl UserRunConfig { } } -#[cfg(test)] +#[cfg(all(test, not(clippy)))] mod ts_tests { + // test code: uses std types for convenience + #[expect(clippy::disallowed_types, reason = "test code uses std types for convenience")] use std::{env, path::PathBuf}; use super::UserRunConfig; #[test] + // test code: uses std types for convenience + #[expect( + clippy::disallowed_methods, + clippy::disallowed_types, + reason = "test code uses std types for convenience" + )] fn typescript_generation() { let file_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("run-config.ts"); - let ts = UserRunConfig::generate_ts_definition().replace("\r", ""); + let ts = UserRunConfig::generate_ts_definition().replace('\r', ""); if env::var("VT_UPDATE_TS_TYPES").unwrap_or_default() == "1" { std::fs::write(&file_path, ts).unwrap(); @@ -268,8 +292,8 @@ mod tests { UserCacheConfig::Enabled { cache: Some(MustBe!(true)), enabled_cache_config: EnabledCacheConfig { - envs: Some(["NODE_ENV".into()].into_iter().collect()), - pass_through_envs: Some(["FOO".into()].into_iter().collect()), + envs: Some(std::iter::once("NODE_ENV".into()).collect()), + pass_through_envs: Some(std::iter::once("FOO".into()).collect()), } }, ); diff --git a/crates/vite_task_graph/src/display.rs b/crates/vite_task_graph/src/display.rs index db4a01b2..3e60b5a1 100644 --- a/crates/vite_task_graph/src/display.rs +++ b/crates/vite_task_graph/src/display.rs @@ -29,6 +29,7 @@ impl Display for TaskDisplay { impl IndexedTaskGraph { /// Get human-readable display for a task node. + #[must_use] pub fn display_task(&self, task_index: TaskNodeIndex) -> TaskDisplay { self.task_graph()[task_index].task_display.clone() } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index e0e8fa74..f578b73a 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -5,11 +5,7 @@ mod package_graph; pub mod query; mod specifier; -use std::{ - collections::{HashMap, hash_map::Entry}, - convert::Infallible, - sync::Arc, -}; +use std::{convert::Infallible, sync::Arc}; use config::{ResolvedTaskConfig, UserRunConfig}; use package_graph::IndexedPackageGraph; @@ -17,9 +13,9 @@ use petgraph::{ graph::{DefaultIx, DiGraph, EdgeIndex, IndexType, NodeIndex}, visit::{Control, DfsEvent, depth_first_search}, }; +use rustc_hash::{FxBuildHasher, FxHashMap}; use serde::Serialize; pub use specifier::TaskSpecifier; -use vec1::smallvec_v1::SmallVec1; use vite_path::AbsolutePath; use vite_str::Str; use vite_workspace::{PackageNodeIndex, WorkspaceRoot}; @@ -44,11 +40,13 @@ pub struct TaskDependencyType(TaskDependencyTypeInner); // It hides `TaskDependencyTypeInner` and only expose `is_explicit`/`is_topological` // to avoid incorrectly matching only Explicit variant to check if it's explicit. impl TaskDependencyType { - pub fn is_explicit(self) -> bool { + #[must_use] + pub const fn is_explicit(self) -> bool { matches!(self.0, TaskDependencyTypeInner::Explicit | TaskDependencyTypeInner::Both) } - pub fn is_topological(self) -> bool { + #[must_use] + pub const fn is_topological(self) -> bool { matches!(self.0, TaskDependencyTypeInner::Topological | TaskDependencyTypeInner::Both) } } @@ -81,6 +79,7 @@ pub struct TaskNode { impl vite_graph_ser::GetKey for TaskNode { type Key<'a> = (&'a AbsolutePath, &'a str); + #[expect(clippy::disallowed_types, reason = "trait requires String as error type")] fn key(&self) -> Result, String> { Ok((&self.task_display.package_path, &self.task_display.task_name)) } @@ -150,6 +149,7 @@ pub enum SpecifierLookupError { /// newtype of `DefaultIx` for indices in task graphs #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub struct TaskIx(DefaultIx); +// SAFETY: TaskIx is a newtype over DefaultIx which already implements IndexType correctly unsafe impl IndexType for TaskIx { fn new(x: usize) -> Self { Self(DefaultIx::new(x)) @@ -167,7 +167,7 @@ unsafe impl IndexType for TaskIx { pub type TaskNodeIndex = NodeIndex; pub type TaskEdgeIndex = EdgeIndex; -/// Full task graph of a workspace, with necessary HashMaps for quick task lookup +/// Full task graph of a workspace, with necessary hash maps for quick task lookup /// /// It's immutable after created. The task nodes contain resolved task configurations and their dependencies. /// External factors (e.g. additional args from cli, current working directory, environmental variables) are not stored here. @@ -176,18 +176,32 @@ pub struct IndexedTaskGraph { task_graph: DiGraph, /// Preserve the package graph for two purposes: - /// - `self.task_graph` refers packages via PackageNodeIndex. To display package names and paths, we need to lookup them in package_graph. + /// - `self.task_graph` refers packages via `PackageNodeIndex`. To display package names and paths, we need to lookup them in `package_graph`. /// - To find nearest topological tasks when the starting package itself doesn't contain the task with the given name. indexed_package_graph: IndexedPackageGraph, /// task indices by task id for quick lookup - node_indices_by_task_id: HashMap, + node_indices_by_task_id: FxHashMap, } pub type TaskGraph = DiGraph; impl IndexedTaskGraph { /// Load the task graph from a discovered workspace using the provided config loader. + /// + /// # Errors + /// + /// Returns [`TaskGraphLoadError`] if the package graph fails to load, a config file + /// cannot be read, a task config cannot be resolved, a dependency specifier is invalid, + /// or `cacheScripts` is set in a non-root package. + #[expect( + clippy::too_many_lines, + reason = "graph loading is inherently sequential and multi-step" + )] + #[expect( + clippy::future_not_send, + reason = "UserConfigLoader uses async_trait(?Send) so the future is intentionally not Send" + )] pub async fn load( workspace_root: &WorkspaceRoot, config_loader: &dyn loader::UserConfigLoader, @@ -200,8 +214,8 @@ impl IndexedTaskGraph { let mut task_ids_with_dependency_specifiers: Vec<(TaskId, Option>)> = Vec::new(); // index tasks by ids - let mut node_indices_by_task_id: HashMap = - HashMap::with_capacity(task_graph.node_count()); + let mut node_indices_by_task_id: FxHashMap = + FxHashMap::with_capacity_and_hasher(task_graph.node_count(), FxBuildHasher); // First pass: load all configs, extract cacheScripts from root, validate let mut cache_scripts = false; // Default: disabled @@ -240,7 +254,7 @@ impl IndexedTaskGraph { let package = &package_graph[package_index]; // Collect package.json scripts into a mutable map for draining lookup. - let mut package_json_scripts: HashMap<&str, &str> = package + let mut package_json_scripts: FxHashMap<&str, &str> = package .package_json .scripts .iter() @@ -285,7 +299,7 @@ impl IndexedTaskGraph { } // For remaining package.json scripts not defined in vite.config.*, create tasks with default config - for (script_name, package_json_script) in package_json_scripts.drain() { + for (script_name, package_json_script) in package_json_scripts { let task_id = TaskId { task_name: Str::from(script_name), package_index }; let resolved_config = ResolvedTaskConfig::resolve_package_json_script( &package_dir, @@ -304,21 +318,6 @@ impl IndexedTaskGraph { } } - // Grouping package indices by their package names. - let mut package_indices_by_name: HashMap> = - HashMap::new(); - for package_index in package_graph.node_indices() { - let package = &package_graph[package_index]; - match package_indices_by_name.entry(package.package_json.name.clone()) { - Entry::Vacant(vacant) => { - vacant.insert(SmallVec1::new(package_index)); - } - Entry::Occupied(occupied) => { - occupied.into_mut().push(package_index); - } - } - } - // Construct `Self` with task_graph with all task nodes ready and indexed, but no edges. let mut me = Self { task_graph, @@ -349,19 +348,19 @@ impl IndexedTaskGraph { } // Add topological dependencies based on package dependencies - let mut nearest_topological_tasks = Vec::::new(); for (task_id, task_index) in &me.node_indices_by_task_id { let task_name = task_id.task_name.as_str(); let package_index = task_id.package_index; // For every task, find nearest tasks with the same name. // If there is a task with the same name in a deeper dependency, it will be connected via that nearer dependency's task. + let mut nearest_topological_tasks = Vec::::new(); me.find_nearest_topological_tasks( task_name, package_index, &mut nearest_topological_tasks, ); - for nearest_task_index in nearest_topological_tasks.drain(..) { + for nearest_task_index in nearest_topological_tasks { if let Some(existing_edge_index) = me.task_graph.find_edge(*task_index, nearest_task_index) { @@ -420,19 +419,19 @@ impl IndexedTaskGraph { return Control::<()>::Continue; }; - if let Some(dependency_task_index) = self.node_indices_by_task_id.get(&TaskId { - package_index: dependency_package_index, - task_name: task_name.into(), - }) { - // Encountered a package containing the task with the same name - // collect the task index - out.push(*dependency_task_index); - - // And stop searching further down this branch - Control::Prune - } else { - Control::Continue - } + self.node_indices_by_task_id + .get(&TaskId { + package_index: dependency_package_index, + task_name: task_name.into(), + }) + .map_or(Control::Continue, |dependency_task_index| { + // Encountered a package containing the task with the same name + // collect the task index + out.push(*dependency_task_index); + + // And stop searching further down this branch + Control::Prune + }) }, ); } @@ -450,13 +449,11 @@ impl IndexedTaskGraph { let Some(package_indices) = self.indexed_package_graph.get_package_indices_by_name(&package_name) else { - return Err(SpecifierLookupError::PackageNameNotFound { - package_name: package_name.into(), - }); + return Err(SpecifierLookupError::PackageNameNotFound { package_name }); }; if package_indices.len() > 1 { return Err(SpecifierLookupError::AmbiguousPackageName { - package_name: package_name.into(), + package_name, package_paths: package_indices .iter() .map(|package_index| { @@ -467,7 +464,7 @@ impl IndexedTaskGraph { }) .collect(), }); - }; + } *package_indices.first() } else { // No '#', so the specifier only contains task name, look up in the origin path package @@ -483,25 +480,29 @@ impl IndexedTaskGraph { .package_json .name .clone(), - task_name: task_id_to_lookup.task_name.clone(), + task_name: task_id_to_lookup.task_name, package_index, }); }; Ok(*node_index) } - pub fn task_graph(&self) -> &TaskGraph { + #[must_use] + pub const fn task_graph(&self) -> &TaskGraph { &self.task_graph } + #[must_use] pub fn get_package_name(&self, package_index: PackageNodeIndex) -> &str { self.indexed_package_graph.package_graph()[package_index].package_json.name.as_str() } + #[must_use] pub fn get_package_path(&self, package_index: PackageNodeIndex) -> &Arc { &self.indexed_package_graph.package_graph()[package_index].absolute_path } + #[must_use] pub fn get_package_path_for_task(&self, task_index: TaskNodeIndex) -> &Arc { &self.task_graph[task_index].task_display.package_path } diff --git a/crates/vite_task_graph/src/package_graph.rs b/crates/vite_task_graph/src/package_graph.rs index a2692520..fd7b6298 100644 --- a/crates/vite_task_graph/src/package_graph.rs +++ b/crates/vite_task_graph/src/package_graph.rs @@ -1,61 +1,55 @@ -use std::{ - collections::{HashMap, hash_map::Entry}, - sync::Arc, -}; +use std::sync::Arc; use petgraph::graph::DiGraph; +use rustc_hash::FxHashMap; use vec1::smallvec_v1::SmallVec1; use vite_path::AbsolutePath; use vite_str::Str; use vite_workspace::{DependencyType, PackageInfo, PackageIx, PackageNodeIndex}; -/// Package graph with additional HashMaps for quick task lookup +/// Package graph with additional hash maps for quick task lookup #[derive(Debug)] pub struct IndexedPackageGraph { - package_graph: DiGraph, + graph: DiGraph, /// Grouping package indices by their package names. /// Due to rare but possible name conflicts in monorepos, we use `SmallVec1` to store multiple dirs for same name. - package_indices_by_name: HashMap>, + indices_by_name: FxHashMap>, /// package indices by their absolute paths for quick lookup based on cwd - package_indices_by_paths: HashMap, PackageNodeIndex>, + indices_by_path: FxHashMap, PackageNodeIndex>, } impl IndexedPackageGraph { pub fn index(package_graph: DiGraph) -> Self { // Index package indices by their absolute paths for quick lookup based on cwd - let package_indices_by_paths = package_graph + let indices_by_path: FxHashMap, PackageNodeIndex> = package_graph .node_indices() .map(|package_index| { let absolute_path: Arc = Arc::clone(&package_graph[package_index].absolute_path); (absolute_path, package_index) }) - .collect::, PackageNodeIndex>>(); + .collect(); // Grouping package indices by their package names. - let mut package_indices_by_name: HashMap> = - HashMap::new(); + let mut indices_by_name: FxHashMap> = + FxHashMap::default(); for package_index in package_graph.node_indices() { let package = &package_graph[package_index]; - match package_indices_by_name.entry(package.package_json.name.clone()) { - Entry::Vacant(vacant) => { - vacant.insert(SmallVec1::new(package_index)); - } - Entry::Occupied(occupied) => { - occupied.into_mut().push(package_index); - } - } + indices_by_name + .entry(package.package_json.name.clone()) + .and_modify(|indices| indices.push(package_index)) + .or_insert_with(|| SmallVec1::new(package_index)); } - Self { package_graph, package_indices_by_name, package_indices_by_paths } + Self { graph: package_graph, indices_by_name, indices_by_path } } /// Get package index from a given current working directory by traversing up the directory tree. pub fn get_package_index_from_cwd(&self, cwd: &AbsolutePath) -> Option { let mut cur_path = cwd; loop { - if let Some(package_index) = self.package_indices_by_paths.get(cur_path) { + if let Some(package_index) = self.indices_by_path.get(cur_path) { return Some(*package_index); } cur_path = cur_path.parent()?; @@ -67,10 +61,10 @@ impl IndexedPackageGraph { &self, package_name: &Str, ) -> Option<&SmallVec1<[PackageNodeIndex; 1]>> { - self.package_indices_by_name.get(package_name) + self.indices_by_name.get(package_name) } - pub fn package_graph(&self) -> &DiGraph { - &self.package_graph + pub const fn package_graph(&self) -> &DiGraph { + &self.graph } } diff --git a/crates/vite_task_graph/src/query/cli.rs b/crates/vite_task_graph/src/query/cli.rs index b4b575bc..75e9cb8b 100644 --- a/crates/vite_task_graph/src/query/cli.rs +++ b/crates/vite_task_graph/src/query/cli.rs @@ -1,5 +1,6 @@ -use std::{collections::HashSet, sync::Arc}; +use std::sync::Arc; +use rustc_hash::FxHashSet; use serde::Serialize; use vite_path::AbsolutePath; use vite_str::Str; @@ -39,6 +40,12 @@ pub enum CLITaskQueryError { impl CLITaskQuery { /// Convert to `TaskQuery`, or return an error if invalid. + /// + /// # Errors + /// + /// Returns [`CLITaskQueryError::RecursiveTransitiveConflict`] if both `--recursive` and + /// `--transitive` are set, or [`CLITaskQueryError::PackageNameSpecifiedWithRecursive`] + /// if a package name is specified with `--recursive`. pub fn into_task_query(self, cwd: &Arc) -> Result { let include_explicit_deps = !self.ignore_depends_on; @@ -46,7 +53,7 @@ impl CLITaskQuery { if self.transitive { return Err(CLITaskQueryError::RecursiveTransitiveConflict); } - let task_names: HashSet = self + let task_names: FxHashSet = self .tasks .into_iter() .map(|s| { diff --git a/crates/vite_task_graph/src/query/mod.rs b/crates/vite_task_graph/src/query/mod.rs index 31d685b1..c96dddd4 100644 --- a/crates/vite_task_graph/src/query/mod.rs +++ b/crates/vite_task_graph/src/query/mod.rs @@ -1,8 +1,9 @@ pub mod cli; -use std::{collections::HashSet, sync::Arc}; +use std::sync::Arc; use petgraph::{prelude::DiGraphMap, visit::EdgeRef}; +use rustc_hash::FxHashSet; use serde::Serialize; use vite_path::AbsolutePath; use vite_str::Str; @@ -18,7 +19,7 @@ pub enum TaskQueryKind { /// A normal task query specifying task specifiers and options. /// The tasks will be searched in packages in task specifiers, or located from cwd. Normal { - task_specifiers: HashSet, + task_specifiers: FxHashSet, /// Where the query is being run from. cwd: Arc, /// Whether to include topological dependencies @@ -27,7 +28,7 @@ pub enum TaskQueryKind { /// A recursive task query specifying one or multiple task names. /// It will match all tasks with the given names across all packages with topological ordering. /// The whole workspace will be searched, so cwd is not relevant. - Recursive { task_names: HashSet }, + Recursive { task_names: FxHashSet }, } /// Represents a valid query for a task and its dependencies, usually issued from a CLI command `vp run ...`. @@ -63,6 +64,12 @@ pub enum TaskQueryError { } impl IndexedTaskGraph { + /// Queries the task graph based on the given [`TaskQuery`] and returns the execution graph. + /// + /// # Errors + /// + /// Returns [`TaskQueryError::SpecifierLookupError`] if a task specifier cannot be resolved + /// to a task in the graph. pub fn query_tasks(&self, query: TaskQuery) -> Result { let mut execution_graph = TaskExecutionGraph::default(); @@ -77,8 +84,6 @@ impl IndexedTaskGraph { let package_index_from_cwd = self.indexed_package_graph.get_package_index_from_cwd(&cwd); - let mut nearest_topological_tasks = Vec::::new(); - // For every task specifier, add matching tasks for specifier in task_specifiers { // Find the starting task @@ -99,6 +104,7 @@ impl IndexedTaskGraph { if include_topological_deps => { // try to find nearest task + let mut nearest_topological_tasks = Vec::::new(); self.find_nearest_topological_tasks( &specifier.task_name, package_index, @@ -113,7 +119,7 @@ impl IndexedTaskGraph { } // Add nearest tasks to execution graph // Topological dependencies of nearest tasks will be added later - for nearest_task in nearest_topological_tasks.drain(..) { + for nearest_task in nearest_topological_tasks { execution_graph.add_node(nearest_task); } } @@ -158,13 +164,13 @@ impl IndexedTaskGraph { execution_graph: &mut TaskExecutionGraph, mut filter_edge: impl FnMut(TaskDependencyType) -> bool, ) { - let mut current_starting_node_indices: HashSet = + let mut current_starting_node_indices: FxHashSet = execution_graph.nodes().collect(); // Continue until no new nodes are added while !current_starting_node_indices.is_empty() { // Record newly added nodes in this iteration as starting nodes for next iteration - let mut next_starting_node_indices = HashSet::::new(); + let mut next_starting_node_indices = FxHashSet::::default(); for from_node in current_starting_node_indices { // For each starting node, traverse its outgoing edges diff --git a/crates/vite_task_graph/src/specifier.rs b/crates/vite_task_graph/src/specifier.rs index c69241ff..c495dc9c 100644 --- a/crates/vite_task_graph/src/specifier.rs +++ b/crates/vite_task_graph/src/specifier.rs @@ -16,21 +16,19 @@ pub struct TaskSpecifier { impl Display for TaskSpecifier { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(package_name) = &self.package_name { - write!(f, "{}#", package_name)? + write!(f, "{package_name}#")?; } write!(f, "{}", self.task_name) } } impl TaskSpecifier { + #[must_use] pub fn parse_raw(raw_specifier: &str) -> Self { if let Some((package_name, task_name)) = raw_specifier.rsplit_once('#') { - TaskSpecifier { - package_name: Some(Str::from(package_name)), - task_name: Str::from(task_name), - } + Self { package_name: Some(Str::from(package_name)), task_name: Str::from(task_name) } } else { - TaskSpecifier { package_name: None, task_name: Str::from(raw_specifier) } + Self { package_name: None, task_name: Str::from(raw_specifier) } } } } @@ -39,6 +37,6 @@ impl FromStr for TaskSpecifier { type Err = Infallible; fn from_str(s: &str) -> Result { - Ok(TaskSpecifier::parse_raw(s)) + Ok(Self::parse_raw(s)) } } diff --git a/crates/vite_task_plan/Cargo.toml b/crates/vite_task_plan/Cargo.toml index b3585966..cd314ac6 100644 --- a/crates/vite_task_plan/Cargo.toml +++ b/crates/vite_task_plan/Cargo.toml @@ -16,6 +16,7 @@ async-trait = { workspace = true } bincode = { workspace = true } futures-util = { workspace = true } petgraph = { workspace = true } +rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } sha2 = { workspace = true } shell-escape = { workspace = true } diff --git a/crates/vite_task_plan/src/cache_metadata.rs b/crates/vite_task_plan/src/cache_metadata.rs index 13064cd3..9fcb3215 100644 --- a/crates/vite_task_plan/src/cache_metadata.rs +++ b/crates/vite_task_plan/src/cache_metadata.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; use vite_path::RelativePathBuf; -use vite_str::Str; +use vite_str::{self, Str}; use crate::envs::EnvFingerprints; @@ -18,7 +18,7 @@ pub enum ExecutionCacheKey { /// This is to distinguish multiple execution items from the same task. and_item_index: usize, /// Extra args provided when invoking the user-defined task (`vp [task_name] [extra_args...]`). - /// These args are appended to the last and_item. Non-last and_items don't get extra args. + /// These args are appended to the last `and_item`. Non-last `and_items` don't get extra args. extra_args: Arc<[Str]>, /// The package path where the user-defined task is defined, relative to the workspace root. package_path: RelativePathBuf, @@ -30,6 +30,7 @@ pub enum ExecutionCacheKey { } /// Cache information for a spawn execution. +/// /// It only contains information needed for hitting existing cache entries pre-execution. /// It doesn't contain any post-execution information like file fingerprints /// (which needs actual execution and is out of scope for planning). @@ -82,27 +83,32 @@ pub struct SpawnFingerprint { impl SpawnFingerprint { /// Get the fingerprint ignores patterns. - pub fn fingerprint_ignores(&self) -> Option<&Vec> { + #[must_use] + pub const fn fingerprint_ignores(&self) -> Option<&Vec> { self.fingerprint_ignores.as_ref() } /// Get the environment fingerprints. - pub fn env_fingerprints(&self) -> &EnvFingerprints { + #[must_use] + pub const fn env_fingerprints(&self) -> &EnvFingerprints { &self.env_fingerprints } /// Get the program fingerprint as a debug string. - pub fn program_fingerprint_debug(&self) -> String { - format!("{:?}", self.program_fingerprint) + #[must_use] + pub fn program_fingerprint_debug(&self) -> Str { + vite_str::format!("{:?}", self.program_fingerprint) } /// Get the command args. - pub fn args(&self) -> &Arc<[Str]> { + #[must_use] + pub const fn args(&self) -> &Arc<[Str]> { &self.args } /// Get the working directory. - pub fn cwd(&self) -> &RelativePathBuf { + #[must_use] + pub const fn cwd(&self) -> &RelativePathBuf { &self.cwd } } diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index 89659183..3b233bc0 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -1,7 +1,6 @@ -use std::{ - collections::HashMap, env::JoinPathsError, ffi::OsStr, fmt::Display, ops::Range, sync::Arc, -}; +use std::{env::JoinPathsError, ffi::OsStr, fmt::Display, ops::Range, sync::Arc}; +use rustc_hash::FxHashMap; use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::{IndexedTaskGraph, TaskNodeIndex, display::TaskDisplay}; @@ -27,7 +26,7 @@ pub struct PlanContext<'a> { cwd: Arc, /// The environment variables for the current execution context. - envs: HashMap, Arc>, + envs: FxHashMap, Arc>, /// The callbacks for loading task graphs and parsing commands. callbacks: &'a mut (dyn PlanRequestParser + 'a), @@ -47,7 +46,7 @@ pub struct PlanContext<'a> { pub struct TaskCallStackFrameDisplay { pub task_display: TaskDisplay, - #[expect(dead_code)] // To be used in terminal error display + #[expect(dead_code, reason = "to be used in terminal error display")] pub command_span: Range, } @@ -76,7 +75,7 @@ impl Display for TaskCallStackDisplay { if i > 0 { write!(f, " -> ")?; } - write!(f, "{}", frame)?; + write!(f, "{frame}")?; } Ok(()) } @@ -86,7 +85,7 @@ impl<'a> PlanContext<'a> { pub fn new( workspace_path: &'a Arc, cwd: Arc, - envs: HashMap, Arc>, + envs: FxHashMap, Arc>, callbacks: &'a mut (dyn PlanRequestParser + 'a), indexed_task_graph: &'a IndexedTaskGraph, ) -> Self { @@ -97,11 +96,11 @@ impl<'a> PlanContext<'a> { callbacks, task_call_stack: Vec::new(), indexed_task_graph, - extra_args: Default::default(), + extra_args: Arc::default(), } } - pub fn envs(&self) -> &HashMap, Arc> { + pub const fn envs(&self) -> &FxHashMap, Arc> { &self.envs } @@ -132,11 +131,11 @@ impl<'a> PlanContext<'a> { Ok(()) } - pub fn indexed_task_graph(&self) -> &'a IndexedTaskGraph { + pub const fn indexed_task_graph(&self) -> &'a IndexedTaskGraph { self.indexed_task_graph } - pub fn workspace_path(&self) -> &Arc { + pub const fn workspace_path(&self) -> &Arc { self.workspace_path } @@ -162,7 +161,7 @@ impl<'a> PlanContext<'a> { } } - pub fn extra_args(&self) -> &Arc<[Str]> { + pub const fn extra_args(&self) -> &Arc<[Str]> { &self.extra_args } diff --git a/crates/vite_task_plan/src/envs.rs b/crates/vite_task_plan/src/envs.rs index 9aabfbc4..8d537db8 100644 --- a/crates/vite_task_plan/src/envs.rs +++ b/crates/vite_task_plan/src/envs.rs @@ -1,10 +1,7 @@ -use std::{ - collections::{BTreeMap, HashMap}, - ffi::OsStr, - sync::Arc, -}; +use std::{collections::BTreeMap, ffi::OsStr, sync::Arc}; use bincode::{Decode, Encode}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use supports_color::{Stream, on}; @@ -45,9 +42,9 @@ impl EnvFingerprints { /// Resolves from all available envs and env config. /// /// Before the call, `all_envs` is expected to contain all available envs. - /// After the call, it will be modified to contain only envs to be passed to the execution (fingerprinted + pass_through). + /// After the call, it will be modified to contain only envs to be passed to the execution (fingerprinted + `pass_through`). pub fn resolve( - all_envs: &mut HashMap, Arc>, + all_envs: &mut FxHashMap, Arc>, env_config: &EnvConfig, ) -> Result { // Collect all envs matching fingerpinted or pass-through envs in env_config @@ -110,6 +107,10 @@ impl EnvFingerprints { let value: Arc = if sensitive_patterns.is_match(name) { let mut hasher = Sha256::new(); hasher.update(value.as_bytes()); + #[expect( + clippy::disallowed_macros, + reason = "result is converted to Arc, not Str" + )] format!("sha256:{:x}", hasher.finalize()).into() } else { value.into() @@ -129,7 +130,7 @@ impl EnvFingerprints { fn resolve_envs_with_patterns<'a>( env_vars: impl Iterator, &'a Arc)>, patterns: &[&str], -) -> Result, Arc>, vite_glob::Error> { +) -> Result, Arc>, vite_glob::Error> { let patterns = GlobPatternSet::new(patterns.iter().filter(|pattern| { if pattern.starts_with('!') { // FIXME: use better way to print warning log @@ -143,14 +144,11 @@ fn resolve_envs_with_patterns<'a>( true } }))?; - let envs: HashMap, Arc> = env_vars + let envs: FxHashMap, Arc> = env_vars .filter_map(|(name, value)| { - let Some(name_str) = name.as_ref().to_str() else { - return None; - }; - + let name_str = name.as_ref().to_str()?; if patterns.is_match(name_str) { - Some((Arc::clone(&name), Arc::clone(&value))) + Some((Arc::clone(name), Arc::clone(value))) } else { None } @@ -184,11 +182,9 @@ const SENSITIVE_PATTERNS: &[&str] = &[ #[cfg(test)] mod tests { - use std::collections::HashMap; - use super::*; - fn create_test_envs(pairs: Vec<(&str, &str)>) -> HashMap, Arc> { + fn create_test_envs(pairs: Vec<(&str, &str)>) -> FxHashMap, Arc> { pairs .into_iter() .map(|(k, v)| (Arc::from(OsStr::new(k)), Arc::from(OsStr::new(v)))) @@ -343,9 +339,18 @@ mod tests { "Unix should treat different cases as different variables" ); - assert_eq!(fingerprinted_envs.get("TEST_VAR").map(|s| s.as_ref()), Some("uppercase")); - assert_eq!(fingerprinted_envs.get("test_var").map(|s| s.as_ref()), Some("lowercase")); - assert_eq!(fingerprinted_envs.get("Test_Var").map(|s| s.as_ref()), Some("mixed")); + assert_eq!( + fingerprinted_envs.get("TEST_VAR").map(std::convert::AsRef::as_ref), + Some("uppercase") + ); + assert_eq!( + fingerprinted_envs.get("test_var").map(std::convert::AsRef::as_ref), + Some("lowercase") + ); + assert_eq!( + fingerprinted_envs.get("Test_Var").map(std::convert::AsRef::as_ref), + Some("mixed") + ); } #[test] @@ -475,9 +480,8 @@ mod tests { // Create invalid UTF-8 sequence let invalid_utf8 = OsStr::from_bytes(&[0xff, 0xfe]); - let mut all_envs: HashMap, Arc> = - [(Arc::from(OsStr::new("INVALID_UTF8")), Arc::from(invalid_utf8))] - .into_iter() + let mut all_envs: FxHashMap, Arc> = + std::iter::once((Arc::from(OsStr::new("INVALID_UTF8")), Arc::from(invalid_utf8))) .collect(); let result = EnvFingerprints::resolve(&mut all_envs, &env_config); @@ -487,7 +491,9 @@ mod tests { ResolveEnvError::EnvValueIsNotValidUnicode { key, .. } => { assert_eq!(key.as_str(), "INVALID_UTF8"); } - other => panic!("Expected EnvValueIsNotValidUnicode, got {:?}", other), + other @ ResolveEnvError::GlobError { .. } => { + panic!("Expected EnvValueIsNotValidUnicode, got {other:?}") + } } } diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index f313f7fb..35212f8e 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -1,4 +1,9 @@ -use std::{env::JoinPathsError, ffi::OsStr, fmt::Display, path::Path, sync::Arc}; +#[expect( + clippy::disallowed_types, + reason = "Arc is used for non-UTF-8 path data in error types" +)] +use std::path::Path; +use std::{env::JoinPathsError, ffi::OsStr, fmt::Display, sync::Arc}; use vite_path::{AbsolutePath, relative::InvalidPathDataError}; use vite_str::Str; @@ -27,11 +32,16 @@ pub struct WhichError { } impl Display for WhichError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Failed to find executable {:?} under cwd {:?} with ", self.program, self.cwd)?; + write!( + f, + "Failed to find executable {} under cwd {} with ", + self.program.display(), + self.cwd.as_path().display() + )?; if let Some(path_env) = &self.path_env { - write!(f, "PATH: {:?}", path_env)? + write!(f, "PATH: {}", path_env.display())?; } else { - write!(f, "No PATH")? + write!(f, "No PATH")?; } Ok(()) } @@ -43,6 +53,7 @@ pub enum PathFingerprintErrorKind { PathOutsideWorkspace { path: Arc, workspace_path: Arc }, #[error("Path {path:?} contains characters that make it non-portable")] NonPortableRelativePath { + #[expect(clippy::disallowed_types, reason = "path may contain non-UTF-8 data")] path: Arc, #[source] error: InvalidPathDataError, @@ -58,9 +69,9 @@ pub enum PathType { impl Display for PathType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PathType::Cwd => write!(f, "current working directory"), - PathType::Program => write!(f, "program path"), - PathType::PackagePath => write!(f, "package path"), + Self::Cwd => write!(f, "current working directory"), + Self::Program => write!(f, "program path"), + Self::PackagePath => write!(f, "package path"), } } } @@ -137,19 +148,24 @@ impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Failed to plan execution")?; if !self.task_call_stack.is_empty() { - write!(f, ", task call stack: {}", self.task_call_stack)? + write!(f, ", task call stack: {}", self.task_call_stack)?; } Ok(()) } } impl TaskPlanErrorKind { + #[must_use] pub fn with_empty_call_stack(self) -> Error { Error { task_call_stack: TaskCallStackDisplay::default(), kind: self } } } -pub(crate) trait TaskPlanErrorKindResultExt { +#[expect( + clippy::result_large_err, + reason = "Error wraps TaskPlanErrorKind with call stack for diagnostics" +)] +pub trait TaskPlanErrorKindResultExt { type Ok; /// Attach the current task call stack from the planning context to the error. fn with_plan_context(self, context: &PlanContext<'_>) -> Result; diff --git a/crates/vite_task_plan/src/execution_graph.rs b/crates/vite_task_plan/src/execution_graph.rs index 6ec1c38f..87c4a385 100644 --- a/crates/vite_task_plan/src/execution_graph.rs +++ b/crates/vite_task_plan/src/execution_graph.rs @@ -5,6 +5,7 @@ use crate::TaskExecution; /// newtype of `DefaultIx` for indices in task graphs #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ExecutionIx(DefaultIx); +// SAFETY: ExecutionIx is a newtype over DefaultIx which already implements IndexType correctly unsafe impl IndexType for ExecutionIx { fn new(x: usize) -> Self { Self(DefaultIx::new(x)) diff --git a/crates/vite_task_plan/src/in_process.rs b/crates/vite_task_plan/src/in_process.rs index bcb19599..bea525eb 100644 --- a/crates/vite_task_plan/src/in_process.rs +++ b/crates/vite_task_plan/src/in_process.rs @@ -20,11 +20,11 @@ pub struct InProcessExecution { impl InProcessExecution { /// Execute the in-process execution and return the output. - pub async fn execute(&self) -> InProcessExecutionOutput { + pub fn execute(&self) -> InProcessExecutionOutput { match &self.kind { InProcessExecutionKind::Echo { strings, trailing_newline } => { let mut stdout = Vec::new(); - for s in strings.iter() { + for s in strings { stdout.extend_from_slice(s.as_bytes()); stdout.push(b' '); } @@ -59,6 +59,10 @@ impl InProcessExecution { match name { "echo" => { let mut strings = Vec::new(); + #[expect( + clippy::option_if_let_else, + reason = "side effect (push to strings) makes map_or unsuitable" + )] let trailing_newline = if let Some(first_arg) = args.next() { let first_arg = first_arg.as_ref(); if first_arg == "-n" { @@ -71,9 +75,7 @@ impl InProcessExecution { true }; strings.extend(args.map(|s| s.as_ref().into())); - Some(InProcessExecution { - kind: InProcessExecutionKind::Echo { strings, trailing_newline }, - }) + Some(Self { kind: InProcessExecutionKind::Echo { strings, trailing_newline } }) } _ => None, } diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index 52df2664..2090cc6f 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -8,12 +8,7 @@ mod path_env; mod plan; pub mod plan_request; -use std::{ - collections::{BTreeMap, HashMap}, - ffi::OsStr, - fmt::Debug, - sync::Arc, -}; +use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; use context::PlanContext; use error::TaskPlanErrorKindResultExt; @@ -23,6 +18,7 @@ use in_process::InProcessExecution; pub use path_env::{get_path_env, prepend_path_env}; use plan::{plan_query_request, plan_synthetic_request}; use plan_request::{PlanRequest, SyntheticPlanRequest}; +use rustc_hash::FxHashMap; use serde::{Serialize, ser::SerializeMap as _}; use vite_graph_ser::serialize_by_key; use vite_path::AbsolutePath; @@ -30,6 +26,7 @@ use vite_str::Str; use vite_task_graph::{TaskGraphLoadError, display::TaskDisplay}; /// A resolved spawn execution. +/// /// Unlike tasks in `vite_task_graph`, this struct contains all information needed for execution, /// like resolved environment variables, current working directory, and additional args from cli. #[derive(Debug, Serialize)] @@ -88,6 +85,10 @@ pub struct TaskExecution { impl vite_graph_ser::GetKey for TaskExecution { type Key<'a> = (&'a AbsolutePath, &'a str); + #[expect( + clippy::disallowed_types, + reason = "vite_graph_ser::GetKey uses String in its trait definition" + )] fn key(&self) -> Result, String> { Ok((&self.task_display.package_path, &self.task_display.task_name)) } @@ -131,15 +132,20 @@ pub struct ExecutionItem { /// The kind of a leaf execution item, which cannot be expanded further. #[derive(Debug, Serialize)] +#[expect(clippy::large_enum_variant, reason = "SpawnExecution is large but not worth boxing")] pub enum LeafExecutionKind { /// The execution is a spawn of a child process Spawn(SpawnExecution), - /// The execution is done in-process by InProcessExecution::execute() + /// The execution is done in-process by `InProcessExecution::execute()` InProcess(InProcessExecution), } /// An execution item, from a split subcommand in a task's command (`item1 && item2 && ...`). #[derive(Debug, Serialize)] +#[expect( + clippy::large_enum_variant, + reason = "variants are used in-place, boxing would add indirection" +)] pub enum ExecutionItemKind { /// Expanded from a known vp subcommand, like `vp run ...` or a synthesized task. Expanded(#[serde(serialize_with = "serialize_by_key")] ExecutionGraph), @@ -181,15 +187,21 @@ pub struct ExecutionPlan { } impl ExecutionPlan { - pub fn root_node(&self) -> &ExecutionItemKind { + #[must_use] + pub const fn root_node(&self) -> &ExecutionItemKind { &self.root_node } + /// Plan an execution from a plan request. + /// + /// # Errors + /// Returns an error if task graph loading, query, or execution planning fails. + #[expect(clippy::future_not_send, reason = "PlanRequestParser and TaskGraphLoader are !Send")] pub async fn plan( plan_request: PlanRequest, workspace_path: &Arc, cwd: &Arc, - envs: &HashMap, Arc>, + envs: &FxHashMap, Arc>, plan_request_parser: &mut (dyn PlanRequestParser + '_), task_graph_loader: &mut (dyn TaskGraphLoader + '_), ) -> Result { @@ -198,7 +210,7 @@ impl ExecutionPlan { let indexed_task_graph = task_graph_loader .load_task_graph() .await - .map_err(|load_error| TaskPlanErrorKind::TaskGraphLoadError(load_error)) + .map_err(TaskPlanErrorKind::TaskGraphLoadError) .with_empty_call_stack()?; let context = PlanContext::new( @@ -206,7 +218,7 @@ impl ExecutionPlan { Arc::clone(cwd), envs.clone(), plan_request_parser, - &indexed_task_graph, + indexed_task_graph, ); let execution_graph = plan_query_request(query_plan_request, context).await?; ExecutionItemKind::Expanded(execution_graph) @@ -214,7 +226,7 @@ impl ExecutionPlan { PlanRequest::Synthetic(synthetic_plan_request) => { let execution = plan_synthetic_request( workspace_path, - &Default::default(), + &BTreeMap::default(), synthetic_plan_request, None, cwd, @@ -226,6 +238,11 @@ impl ExecutionPlan { Ok(Self { root_node }) } + /// Plan a synthetic task execution. + /// + /// # Errors + /// Returns an error if the program is not found or path fingerprinting fails. + #[expect(clippy::result_large_err, reason = "Error contains task call stack for diagnostics")] pub fn plan_synthetic( workspace_path: &Arc, cwd: &Arc, @@ -235,7 +252,7 @@ impl ExecutionPlan { let execution_cache_key = cache_metadata::ExecutionCacheKey::ExecAPI(cache_key); let execution = plan_synthetic_request( workspace_path, - &Default::default(), + &BTreeMap::default(), synthetic_plan_request, Some(execution_cache_key), cwd, diff --git a/crates/vite_task_plan/src/path_env.rs b/crates/vite_task_plan/src/path_env.rs index 480f6c12..fca82d59 100644 --- a/crates/vite_task_plan/src/path_env.rs +++ b/crates/vite_task_plan/src/path_env.rs @@ -1,17 +1,19 @@ use std::{ - collections::HashMap, env::{JoinPathsError, join_paths, split_paths}, ffi::OsStr, iter, sync::Arc, }; +use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Get the PATH environment variable from the given envs map. /// On Windows, this function performs a case-insensitive search for "PATH". /// On Unix, it performs a case-sensitive search. -pub fn get_path_env(envs: &HashMap, Arc>) -> Option<&Arc> { +#[must_use] +#[expect(clippy::implicit_hasher, reason = "function is specific to FxHashMap")] +pub fn get_path_env(envs: &FxHashMap, Arc>) -> Option<&Arc> { if cfg!(windows) { // On Windows, environment variable names are case-insensitive (e.g., "PATH", "Path", "path" are all the same) // However, Rust's HashMap keys are case-sensitive, so we need to find the existing PATH variable @@ -27,8 +29,13 @@ pub fn get_path_env(envs: &HashMap, Arc>) -> Option<&Arc, Arc>, + envs: &mut FxHashMap, Arc>, path_to_prepend: &AbsolutePath, ) -> Result<(), JoinPathsError> { // Add node_modules/.bin to PATH @@ -66,7 +73,7 @@ pub fn prepend_path_env( mod tests { use super::*; - fn create_test_envs(pairs: Vec<(&str, &str)>) -> HashMap, Arc> { + fn create_test_envs(pairs: Vec<(&str, &str)>) -> FxHashMap, Arc> { pairs .into_iter() .map(|(k, v)| (Arc::from(OsStr::new(k)), Arc::from(OsStr::new(v)))) @@ -92,11 +99,12 @@ mod tests { assert!(path_value.to_str().unwrap().contains("C:\\existing\\path")); // Verify no duplicate PATH entry was created - let path_like_keys: Vec<_> = envs - .keys() - .filter(|k| k.to_str().map(|s| s.eq_ignore_ascii_case("path")).unwrap_or(false)) - .collect(); - assert_eq!(path_like_keys.len(), 1); + assert_eq!( + envs.keys() + .filter(|k| k.to_str().is_some_and(|s| s.eq_ignore_ascii_case("path"))) + .count(), + 1 + ); } #[test] diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index d907639d..48ed3567 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -1,13 +1,18 @@ +#[expect( + clippy::disallowed_types, + reason = "Path is needed for cd command argument and error reporting" +)] +use std::path::Path; use std::{ borrow::Cow, - collections::{BTreeMap, HashMap}, + collections::BTreeMap, env::home_dir, ffi::OsStr, - path::{Path, PathBuf}, sync::{Arc, LazyLock}, }; use futures_util::FutureExt; +use rustc_hash::FxHashMap; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf, relative::InvalidPathDataError}; use vite_shell::try_parse_as_and_list; use vite_str::Str; @@ -34,7 +39,7 @@ use crate::{ /// Locate the executable path for a given program name in the provided envs and cwd. fn which( program: &Arc, - envs: &HashMap, Arc>, + envs: &FxHashMap, Arc>, cwd: &Arc, ) -> Result, crate::error::WhichError> { let path_env = get_path_env(envs); @@ -51,6 +56,8 @@ fn which( .into()) } +#[expect(clippy::too_many_lines, reason = "sequential planning steps are clearer in one function")] +#[expect(clippy::future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser")] async fn plan_task_as_execution_node( task_node_index: TaskNodeIndex, mut context: PlanContext<'_>, @@ -99,6 +106,10 @@ async fn plan_task_as_execution_node( // Handle `cd` builtin command if and_item.program == "cd" { + #[expect( + clippy::disallowed_types, + reason = "Path is needed for std::env::home_dir return type and AbsolutePath::join" + )] let cd_target: Cow<'_, Path> = match args.as_slice() { // No args, go to home directory [] => home_dir() @@ -159,7 +170,7 @@ async fn plan_task_as_execution_node( }; // Try to parse the args of an and_item to a plan request like `run -r build` - let envs: Arc, Arc>> = context.envs().clone().into(); + let envs: Arc, Arc>> = context.envs().clone().into(); let mut script_command = ScriptCommand { program: and_item.program.clone(), args: args.into(), @@ -223,6 +234,23 @@ async fn plan_task_as_execution_node( items.push(ExecutionItem { execution_item_display, kind: execution_item_kind }); } } else { + #[expect(clippy::disallowed_types, reason = "PathBuf needed for which fallback path")] + static SHELL_PROGRAM_PATH: LazyLock> = + LazyLock::new(|| { + if cfg!(target_os = "windows") { + AbsolutePathBuf::new(which::which("cmd.exe").unwrap_or_else(|_| { + std::path::PathBuf::from("C:\\Windows\\System32\\cmd.exe") + })) + .unwrap() + .into() + } else { + AbsolutePath::new("/bin/sh").unwrap().into() + } + }); + + static SHELL_ARGS: &[&str] = + if cfg!(target_os = "windows") { &["/d", "/s", "/c"] } else { &["-c"] }; + let mut context = context.duplicate(); context.push_stack_frame(task_node_index, 0..command_str.len()); @@ -233,22 +261,6 @@ async fn plan_task_as_execution_node( task_display: task_node.task_display.clone(), }; - static SHELL_PROGRAM_PATH: LazyLock> = LazyLock::new(|| { - if cfg!(target_os = "windows") { - AbsolutePathBuf::new( - which::which("cmd.exe") - .unwrap_or_else(|_| PathBuf::from("C:\\Windows\\System32\\cmd.exe")), - ) - .unwrap() - .into() - } else { - AbsolutePath::new("/bin/sh").unwrap().into() - } - }); - - static SHELL_ARGS: &[&str] = - if cfg!(target_os = "windows") { &["/d", "/s", "/c"] } else { &["-c"] }; - let mut script = Str::from(command_str); for arg in context.extra_args().iter() { script.push(' '); @@ -274,7 +286,7 @@ async fn plan_task_as_execution_node( &task_node.resolved_config.resolved_options, context.envs(), Arc::clone(&*SHELL_PROGRAM_PATH), - Arc::from_iter(SHELL_ARGS.iter().map(|s| Str::from(*s)).chain(std::iter::once(script))), + SHELL_ARGS.iter().map(|s| Str::from(*s)).chain(std::iter::once(script)).collect(), ) .with_plan_context(&context)?; items.push(ExecutionItem { @@ -286,6 +298,7 @@ async fn plan_task_as_execution_node( Ok(TaskExecution { task_display: task_node.task_display.clone(), items }) } +#[expect(clippy::result_large_err, reason = "TaskPlanErrorKind is large for diagnostics")] pub fn plan_synthetic_request( workspace_path: &Arc, prefix_envs: &BTreeMap, @@ -303,7 +316,7 @@ pub fn plan_synthetic_request( cwd_relative_to_package: None, depends_on: None, }, - &cwd, + cwd, ); plan_spawn_execution( @@ -321,7 +334,7 @@ fn strip_prefix_for_cache( path: &Arc, workspace_path: &Arc, ) -> Result { - match path.strip_prefix(&*workspace_path) { + match path.strip_prefix(workspace_path) { Ok(Some(rel_path)) => Ok(rel_path), Ok(None) => Err(PathFingerprintErrorKind::PathOutsideWorkspace { path: Arc::clone(path), @@ -334,12 +347,17 @@ fn strip_prefix_for_cache( } } +#[expect(clippy::result_large_err, reason = "TaskPlanErrorKind is large for diagnostics")] +#[expect( + clippy::needless_pass_by_value, + reason = "program_path ownership is needed for Arc construction" +)] fn plan_spawn_execution( workspace_path: &Arc, execution_cache_key: Option, prefix_envs: &BTreeMap, resolved_task_options: &ResolvedTaskOptions, - envs: &HashMap, Arc>, + envs: &FxHashMap, Arc>, program_path: Arc, args: Arc<[Str]>, ) -> Result { @@ -365,15 +383,26 @@ fn plan_spawn_execution( } Err(PathFingerprintErrorKind::PathOutsideWorkspace { path, .. }) => { let program_name_os_str = path.as_path().file_name().unwrap_or_default(); - let Some(program_name_str) = program_name_os_str.to_str() else { - return Err(PathFingerprintError { - kind: PathFingerprintErrorKind::NonPortableRelativePath { - path: Path::new(program_name_os_str).into(), - error: InvalidPathDataError::NonUtf8, - }, - path_type: PathType::Program, + #[expect( + clippy::manual_let_else, + reason = "? operator doesn't apply since early return has a different error type" + )] + let program_name_str = match program_name_os_str.to_str() { + Some(s) => s, + None => { + #[expect( + clippy::disallowed_types, + reason = "Arc for non-UTF-8 path data in error" + )] + return Err(PathFingerprintError { + kind: PathFingerprintErrorKind::NonPortableRelativePath { + path: Path::new(program_name_os_str).into(), + error: InvalidPathDataError::NonUtf8, + }, + path_type: PathType::Program, + } + .into()); } - .into()); }; ProgramFingerprint::OutsideWorkspace { program_name: program_name_str.into() } } @@ -392,7 +421,7 @@ fn plan_spawn_execution( }; if let Some(execution_cache_key) = execution_cache_key { resolved_cache_metadata = - Some(CacheMetadata { execution_cache_key, spawn_fingerprint }); + Some(CacheMetadata { spawn_fingerprint, execution_cache_key }); } } @@ -413,6 +442,7 @@ fn plan_spawn_execution( } /// Expand the parsed task request (like `run -r build`/`lint`) into an execution graph. +#[expect(clippy::future_not_send, reason = "PlanContext contains !Send dyn PlanRequestParser")] pub async fn plan_query_request( query_plan_request: QueryPlanRequest, mut context: PlanContext<'_>, @@ -426,8 +456,9 @@ pub async fn plan_query_request( .with_plan_context(&context)?; let mut execution_node_indices_by_task_index = - HashMap::::with_capacity( + FxHashMap::::with_capacity_and_hasher( task_node_index_graph.node_count(), + rustc_hash::FxBuildHasher, ); let mut execution_graph = ExecutionGraph::with_capacity( diff --git a/crates/vite_task_plan/src/plan_request.rs b/crates/vite_task_plan/src/plan_request.rs index e022ff68..33b06dea 100644 --- a/crates/vite_task_plan/src/plan_request.rs +++ b/crates/vite_task_plan/src/plan_request.rs @@ -1,5 +1,6 @@ -use std::{collections::HashMap, ffi::OsStr, sync::Arc}; +use std::{ffi::OsStr, sync::Arc}; +use rustc_hash::FxHashMap; use vite_path::AbsolutePath; use vite_str::Str; use vite_task_graph::{config::UserCacheConfig, query::TaskQuery}; @@ -13,7 +14,7 @@ use vite_task_graph::{config::UserCacheConfig, query::TaskQuery}; pub struct ScriptCommand { pub program: Str, pub args: Arc<[Str]>, - pub envs: Arc, Arc>>, + pub envs: Arc, Arc>>, pub cwd: Arc, } @@ -33,7 +34,7 @@ pub struct QueryPlanRequest { pub plan_options: PlanOptions, } -/// The request to run a synthetic task (e.g., one generated by TaskSynthesizer from `vp lint` in a script). +/// The request to run a synthetic task (e.g., one generated by `TaskSynthesizer` from `vp lint` in a script). /// Synthetic tasks are not defined in the task graph, but are generated on-the-fly. #[derive(Debug)] pub struct SyntheticPlanRequest { @@ -54,13 +55,13 @@ pub struct SyntheticPlanRequest { /// - To set envs that are not subject to caching but still passed to the spawned child, use `task_options` to configure `pass_through_envs`. /// - To set envs that should be fingerprinted, use `task_options` to configure `envs`. /// - If neither is set, and caching is enabled, these envs will have not effect. - pub envs: Arc, Arc>>, + pub envs: Arc, Arc>>, } #[derive(Debug)] pub enum PlanRequest { /// The request to run tasks queried from the task graph, like `vp run ...`. Query(QueryPlanRequest), - /// The request to run a synthetic task (not defined in the task graph), e.g., from TaskSynthesizer. + /// The request to run a synthetic task (not defined in the task graph), e.g., from `TaskSynthesizer`. Synthetic(SyntheticPlanRequest), } diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index 6d1f720d..d19a946f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -1,17 +1,19 @@ mod redact; -use std::{collections::HashMap, ffi::OsStr, path::Path, sync::Arc}; +use std::{ffi::OsStr, sync::Arc}; use clap::Parser; use copy_dir::copy_dir; +use cow_utils::CowUtils as _; use redact::redact_snapshot; +use rustc_hash::FxHashMap; use tokio::runtime::Runtime; use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vite_str::Str; use vite_task::{Command, Session}; use vite_workspace::find_workspace_root; -/// Local parser wrapper for BuiltInCommand +/// Local parser wrapper for `BuiltInCommand` #[derive(Parser)] #[command(name = "vp")] enum Cli { @@ -33,19 +35,28 @@ struct SnapshotsFile { pub plan_cases: Vec, } -fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, fixture_path: &Path, filter: Option<&str>) { +#[expect(clippy::disallowed_types, reason = "Path required by insta::glob! callback signature")] +fn run_case( + runtime: &Runtime, + tmpdir: &AbsolutePath, + fixture_path: &std::path::Path, + filter: Option<&str>, +) { let fixture_name = fixture_path.file_name().unwrap().to_str().unwrap(); - if fixture_name.starts_with(".") { + if fixture_name.starts_with('.') { return; // skip hidden files like .DS_Store } // Skip if filter doesn't match - if let Some(f) = filter { - if !fixture_name.contains(f) { - return; - } + if let Some(f) = filter + && !fixture_name.contains(f) + { + return; + } + #[expect(clippy::print_stdout, reason = "test progress output for plan snapshot test runner")] + { + println!("{fixture_name}"); } - println!("{}", fixture_name); // Configure insta to write snapshots to fixture directory let mut settings = insta::Settings::clone_current(); settings.set_snapshot_path(fixture_path.join("snapshots")); @@ -55,10 +66,15 @@ fn run_case(runtime: &Runtime, tmpdir: &AbsolutePath, fixture_path: &Path, filte settings.bind(|| run_case_inner(runtime, tmpdir, fixture_path, fixture_name)); } +#[expect( + clippy::disallowed_types, + reason = "Path required by insta::glob! callback; String required by std::fs::read and toml::from_slice" +)] +#[expect(clippy::too_many_lines, reason = "test setup and assertion logic in a single function")] fn run_case_inner( runtime: &Runtime, tmpdir: &AbsolutePath, - fixture_path: &Path, + fixture_path: &std::path::Path, fixture_name: &str, ) { // Copy the case directory to a temporary directory to avoid discovering workspace outside of the test case. @@ -69,26 +85,36 @@ fn run_case_inner( assert_eq!( &stage_path, &*workspace_root.path, - "folder '{}' should be a workspace root", - fixture_name + "folder '{fixture_name}' should be a workspace root" ); let cases_toml_path = fixture_path.join("snapshots.toml"); let cases_file: SnapshotsFile = match std::fs::read(&cases_toml_path) { Ok(content) => toml::from_slice(&content).unwrap(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Default::default(), - Err(err) => panic!("Failed to read cases.toml for fixture {}: {}", fixture_name, err), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => SnapshotsFile::default(), + Err(err) => panic!("Failed to read cases.toml for fixture {fixture_name}: {err}"), }; // Navigate from CARGO_MANIFEST_DIR to packages/tools at the repo root - let repo_root = - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); - let test_bin_path = Arc::::from( - repo_root.join("packages").join("tools").join("node_modules").join(".bin").into_os_string(), - ); + #[expect( + clippy::disallowed_types, + reason = "Path required for CARGO_MANIFEST_DIR path manipulation to locate packages/tools" + )] + let test_bin_path = { + let repo_root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + Arc::::from( + repo_root + .join("packages") + .join("tools") + .join("node_modules") + .join(".bin") + .into_os_string(), + ) + }; // Add packages/tools to PATH so test programs (such as print-file) in fixtures can be found. - let plan_envs: HashMap, Arc> = [ + let plan_envs: FxHashMap, Arc> = [ (Arc::::from(OsStr::new("PATH")), Arc::clone(&test_bin_path)), (Arc::::from(OsStr::new("NO_COLOR")), Arc::::from(OsStr::new("1"))), ] @@ -99,7 +125,7 @@ fn run_case_inner( let workspace_root_str = workspace_root.path.as_path().to_str().unwrap(); let mut owned_callbacks = vite_task_bin::OwnedSessionCallbacks::default(); let mut session = Session::init_with( - plan_envs.into(), + plan_envs, Arc::clone(&workspace_root.path), owned_callbacks.as_callbacks(), ) @@ -109,11 +135,17 @@ fn run_case_inner( let task_graph = match task_graph_result { Ok(task_graph) => task_graph, Err(err) => { - let mut err_str = format!("{err:#}").replace(workspace_root_str, ""); - if cfg!(windows) { - err_str = err_str.replace('\\', "/"); + let err_formatted = vite_str::format!("{err:#}"); + let err_str = err_formatted.as_str().cow_replace(workspace_root_str, ""); + let err_str = + if cfg!(windows) { err_str.as_ref().cow_replace('\\', "/") } else { err_str }; + #[expect( + clippy::disallowed_macros, + reason = "insta::assert_snapshot! internally uses std::format!" + )] + { + insta::assert_snapshot!("task graph load error", err_str.as_ref()); } - insta::assert_snapshot!("task graph load error", err_str); return; } }; @@ -124,22 +156,27 @@ fn run_case_inner( insta::assert_json_snapshot!("task graph", task_graph_json); for plan in cases_file.plan_cases { - let snapshot_name = format!("query - {}", plan.name); + let snapshot_name = vite_str::format!("query - {}", plan.name); let cli = match Cli::try_parse_from( std::iter::once("vp") // dummy program name - .chain(plan.args.iter().map(|s| s.as_str())), + .chain(plan.args.iter().map(vite_str::Str::as_str)), ) { Ok(ok) => ok, Err(err) => { - insta::assert_snapshot!(snapshot_name, err); + #[expect( + clippy::disallowed_macros, + reason = "insta::assert_snapshot! internally uses std::format!" + )] + { + insta::assert_snapshot!(snapshot_name.as_str(), err); + } continue; } }; let Cli::Command(command) = cli; - let run_command = match command { - Command::Run(run_command) => run_command, - _ => panic!("only `run` commands supported in plan tests"), + let Command::Run(run_command) = command else { + panic!("only `run` commands supported in plan tests") }; let plan_result = @@ -148,17 +185,28 @@ fn run_case_inner( let plan = match plan_result { Ok(plan) => plan, Err(err) => { - insta::assert_debug_snapshot!(snapshot_name, err); + #[expect( + clippy::disallowed_macros, + reason = "insta::assert_debug_snapshot! internally uses std::format!" + )] + { + insta::assert_debug_snapshot!(snapshot_name.as_str(), err); + } continue; } }; let plan_json = redact_snapshot(&plan, workspace_root_str); - insta::assert_json_snapshot!(snapshot_name, &plan_json); + insta::assert_json_snapshot!(snapshot_name.as_str(), &plan_json); } }); } +#[expect(clippy::disallowed_types, reason = "Path required by insta::glob! macro callback")] +#[expect( + clippy::disallowed_methods, + reason = "current_dir needed because insta::glob! requires std PathBuf" +)] fn main() { let filter = std::env::args().nth(1); diff --git a/crates/vite_task_plan/tests/plan_snapshots/redact.rs b/crates/vite_task_plan/tests/plan_snapshots/redact.rs index abcbcb3a..caf4742a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/redact.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/redact.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use cow_utils::CowUtils as _; use serde::Serialize; use vite_task_graph::config::DEFAULT_PASSTHROUGH_ENVS; @@ -29,8 +30,12 @@ fn redact_string_in_json(value: &mut serde_json::Value, redactions: &[(&str, &st } /// Strip Windows executable extensions (case-insensitive) for cross-platform consistency +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] fn strip_windows_executable_extension(s: &mut String) { - let lower = s.to_lowercase(); + let lower = s.as_str().cow_to_lowercase(); for ext in [".cmd", ".bat", ".exe", ".com"] { if lower.ends_with(ext) { s.truncate(s.len() - ext.len()); @@ -39,8 +44,11 @@ fn strip_windows_executable_extension(s: &mut String) { } } +#[expect( + clippy::disallowed_types, + reason = "String mutation required by serde_json::Value::String which stores a String" +)] fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { - use cow_utils::CowUtils as _; for (from, to) in redactions { if let Cow::Owned(mut replaced) = s.as_str().cow_replace(from, to) { if cfg!(windows) { @@ -52,6 +60,10 @@ fn redact_string(s: &mut String, redactions: &[(&str, &str)]) { } } +#[expect( + clippy::disallowed_types, + reason = "String required by std::env::var return type and serde_json Value manipulation; Path required for CARGO_MANIFEST_DIR path manipulation" +)] pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_json::Value { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); // Get the packages/tools directory path @@ -67,19 +79,19 @@ pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_js // On Windows, paths might use either backslashes or forward slashes // Try both variants for workspace_root, manifest_dir, and tools_dir - let workspace_root_forward = workspace_root.replace('\\', "/"); - let manifest_dir_forward = manifest_dir.replace('\\', "/"); - let tools_dir_forward = tools_dir_str.replace('\\', "/"); + let workspace_root_forward = workspace_root.cow_replace('\\', "/"); + let manifest_dir_forward = manifest_dir.as_str().cow_replace('\\', "/"); + let tools_dir_forward = tools_dir_str.as_str().cow_replace('\\', "/"); redact_string_in_json( &mut json_value, &[ (workspace_root, ""), - (workspace_root_forward.as_str(), ""), + (workspace_root_forward.as_ref(), ""), (manifest_dir.as_str(), ""), - (manifest_dir_forward.as_str(), ""), + (manifest_dir_forward.as_ref(), ""), (tools_dir_str.as_str(), ""), - (tools_dir_forward.as_str(), ""), + (tools_dir_forward.as_ref(), ""), ], ); @@ -88,8 +100,10 @@ pub fn redact_snapshot(value: &impl Serialize, workspace_root: &str) -> serde_js let serde_json::Value::Object(map) = v else { return; }; - if let Some(serde_json::Value::String(path)) = map.get_mut("PATH") { - *path = path.replace(';', ":"); + if let Some(serde_json::Value::String(path)) = map.get_mut("PATH") + && let Cow::Owned(replaced) = path.as_str().cow_replace(';', ":") + { + *path = replaced; } }); diff --git a/crates/vite_tui/src/action.rs b/crates/vite_tui/src/action.rs index 55724e11..e331536e 100644 --- a/crates/vite_tui/src/action.rs +++ b/crates/vite_tui/src/action.rs @@ -1,4 +1,4 @@ -#[expect(unused)] +#[expect(unused, reason = "TUI actions defined for future use")] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { Tick, @@ -8,8 +8,8 @@ pub enum Action { Resume, Quit, ClearScreen, - Error(String), - Task { task: String, bytes: Box<[u8]> }, + Error(Box), + Task { task: Box, bytes: Box<[u8]> }, Up, Down, SelectTask(usize), diff --git a/crates/vite_tui/src/app.rs b/crates/vite_tui/src/app.rs index 7b637f80..31b32820 100644 --- a/crates/vite_tui/src/app.rs +++ b/crates/vite_tui/src/app.rs @@ -22,10 +22,20 @@ pub struct App { action_rx: mpsc::UnboundedReceiver, tasks_list: TasksList, + #[expect( + clippy::disallowed_types, + reason = "vite_tui is a standalone TUI app, not using vite_str" + )] tasks_pane: FxHashMap, left_panel_area: Rect, } +impl Default for App { + fn default() -> Self { + Self::new() + } +} + impl App { #[must_use] pub fn new() -> Self { @@ -99,7 +109,10 @@ impl App { if size > 0 { processed_buf.extend_from_slice(&buf[..size]); let bytes = processed_buf.iter().copied().collect(); - if action_tx.send(Action::Task { task: task.clone(), bytes }).is_err() { + if action_tx + .send(Action::Task { task: task.clone().into_boxed_str(), bytes }) + .is_err() + { break; } // Clear the processed portion of the buffer @@ -223,7 +236,7 @@ impl App { Action::Resize(w, h) => self.handle_resize(tui, w, h)?, Action::Render => self.render(tui)?, Action::Task { task, bytes } => { - if let Some(pane) = self.tasks_pane.get_mut(&task) { + if let Some(pane) = self.tasks_pane.get_mut(&*task) { pane.process(&bytes); } } @@ -242,10 +255,15 @@ impl App { Ok(()) } + #[expect( + clippy::disallowed_macros, + reason = "vite_tui is a standalone TUI app, not using vite_str" + )] fn render(&mut self, tui: &mut Tui) -> Result<()> { tui.draw(|frame| { if let Err(err) = self.draw(frame) { - let _ = self.action_tx.send(Action::Error(format!("Failed to draw: {err:?}"))); + let _ = + self.action_tx.send(Action::Error(format!("Failed to draw: {err:?}").into())); } })?; Ok(()) diff --git a/crates/vite_tui/src/components/mod.rs b/crates/vite_tui/src/components/mod.rs index 84bfebfc..7de432c2 100644 --- a/crates/vite_tui/src/components/mod.rs +++ b/crates/vite_tui/src/components/mod.rs @@ -17,7 +17,7 @@ use crate::{action::Action, tui::Event}; /// /// Implementors of this trait can be registered with the main application loop and will be able to /// receive events, update state, and be rendered on the screen. -#[expect(unused)] +#[expect(unused, reason = "component trait methods defined for future use")] pub trait Component: Send { /// Register an action handler that can send actions for processing if necessary. /// diff --git a/crates/vite_tui/src/components/tasks_list.rs b/crates/vite_tui/src/components/tasks_list.rs index 2b2b78e8..6991e5f0 100644 --- a/crates/vite_tui/src/components/tasks_list.rs +++ b/crates/vite_tui/src/components/tasks_list.rs @@ -11,6 +11,10 @@ use ratatui::{ use super::{Action, Component}; pub struct TasksList { + #[expect( + clippy::disallowed_types, + reason = "vite_tui is a standalone TUI app, not using vite_str" + )] tasks: Vec, // States selection: usize, @@ -18,6 +22,10 @@ pub struct TasksList { } impl TasksList { + #[expect( + clippy::disallowed_types, + reason = "vite_tui is a standalone TUI app, not using vite_str" + )] pub const fn new(tasks: Vec) -> Self { Self { state: TableState::new(), selection: 0, tasks } } diff --git a/crates/vite_tui/src/config.rs b/crates/vite_tui/src/config.rs index ba16626c..9a75a6f5 100644 --- a/crates/vite_tui/src/config.rs +++ b/crates/vite_tui/src/config.rs @@ -1,3 +1,7 @@ +#[expect( + clippy::disallowed_types, + reason = "vite_tui is a standalone TUI app, not using vite_path/vite_str" +)] use std::{env, path::PathBuf, sync::LazyLock}; // use derive_deref::{Deref, DerefMut}; @@ -24,6 +28,11 @@ use directories::ProjectDirs; // pub styles: Styles, // } +#[expect( + clippy::disallowed_types, + clippy::disallowed_methods, + reason = "vite_tui is a standalone TUI app, not using vite_str" +)] pub static PROJECT_NAME: LazyLock = LazyLock::new(|| env!("CARGO_CRATE_NAME").to_uppercase()); // pub static DATA_FOLDER: LazyLock> = @@ -78,6 +87,10 @@ pub static PROJECT_NAME: LazyLock = // } // } +#[expect( + clippy::disallowed_types, + reason = "vite_tui is a standalone TUI app, not using vite_path" +)] pub fn get_data_dir() -> PathBuf { project_directory().map_or_else( || PathBuf::from(".").join(".data"), diff --git a/crates/vite_tui/src/logging.rs b/crates/vite_tui/src/logging.rs index 04d8cd76..cfb0d853 100644 --- a/crates/vite_tui/src/logging.rs +++ b/crates/vite_tui/src/logging.rs @@ -6,8 +6,18 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use crate::config; +#[expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "vite_tui is a standalone TUI app, not using vite_str" +)] pub static LOG_ENV: LazyLock = LazyLock::new(|| format!("{}_LOG_LEVEL", config::PROJECT_NAME.clone())); +#[expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "vite_tui is a standalone TUI app, not using vite_str" +)] pub static LOG_FILE: LazyLock = LazyLock::new(|| format!("{}.log", env!("CARGO_PKG_NAME"))); /// # Errors diff --git a/crates/vite_tui/src/tui.rs b/crates/vite_tui/src/tui.rs index 0fe49c66..fc074b51 100644 --- a/crates/vite_tui/src/tui.rs +++ b/crates/vite_tui/src/tui.rs @@ -23,7 +23,7 @@ use tokio::{ use tokio_util::sync::CancellationToken; use tracing::error; -#[expect(unused)] +#[expect(unused, reason = "TUI event variants defined for future use")] #[derive(Clone, Debug)] pub enum Event { Init, @@ -34,6 +34,7 @@ pub enum Event { Render, FocusGained, FocusLost, + #[expect(clippy::disallowed_types, reason = "crossterm provides paste content as String")] Paste(String), Key(KeyEvent), Mouse(MouseEvent), diff --git a/crates/vite_workspace/src/error.rs b/crates/vite_workspace/src/error.rs index 10dc01fe..c923fed2 100644 --- a/crates/vite_workspace/src/error.rs +++ b/crates/vite_workspace/src/error.rs @@ -1,4 +1,9 @@ -use std::{io, path::Path, sync::Arc}; +#[expect( + clippy::disallowed_types, + reason = "StripPrefixError carries a raw &Path that may not be valid UTF-8, so it can't use vite_path types" +)] +use std::path::Path; +use std::{io, sync::Arc}; use vite_path::{ AbsolutePath, AbsolutePathBuf, RelativePathBuf, absolute::StripPrefixError, @@ -20,6 +25,10 @@ pub enum Error { #[error( "The stripped path ({stripped_path:?}) is not a valid relative path because: {invalid_path_data_error}" )] + #[expect( + clippy::disallowed_types, + reason = "stripped path may not be valid UTF-8, so it can't use vite_path types" + )] StripPath { stripped_path: Box, invalid_path_data_error: InvalidPathDataError }, // External library errors diff --git a/crates/vite_workspace/src/lib.rs b/crates/vite_workspace/src/lib.rs index 64965855..31da612e 100644 --- a/crates/vite_workspace/src/lib.rs +++ b/crates/vite_workspace/src/lib.rs @@ -132,7 +132,7 @@ impl PackageGraphBuilder { self.id_and_deps_by_path.insert(package_path.clone(), (id, deps)); // Also maintain name to path mapping for dependency resolution - match self.name_to_path.entry(package_name.clone()) { + match self.name_to_path.entry(package_name) { Entry::Vacant(entry) => { entry.insert(SmallVec1::new(package_path)); } @@ -174,6 +174,7 @@ impl PackageGraphBuilder { /// newtype of `DefaultIx` for indices in package graphs #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PackageIx(DefaultIx); +// SAFETY: PackageIx is a newtype over DefaultIx which already implements IndexType correctly unsafe impl petgraph::graph::IndexType for PackageIx { fn new(x: usize) -> Self { Self(DefaultIx::new(x)) @@ -192,6 +193,9 @@ pub type PackageNodeIndex = NodeIndex; pub type PackageEdgeIndex = EdgeIndex; /// Discover the workspace from cwd and load the package graph. +/// +/// # Errors +/// Returns an error if the workspace cannot be found or the package graph cannot be loaded. pub fn discover_package_graph( cwd: impl AsRef, ) -> Result, Error> { @@ -200,6 +204,12 @@ pub fn discover_package_graph( } /// Load the package graph from a discovered workspace. +/// +/// # Errors +/// Returns an error if workspace files cannot be read/parsed, or if packages are outside the workspace root. +/// +/// # Panics +/// Panics if a `package.json` path has no parent directory (should not happen for valid paths). pub fn load_package_graph( workspace_root: &WorkspaceRoot, ) -> Result, Error> { @@ -285,9 +295,10 @@ pub fn load_package_graph( #[cfg(test)] mod tests { - use std::{collections::HashSet, fs}; + use std::fs; use petgraph::visit::EdgeRef; + use rustc_hash::FxHashSet; use tempfile::TempDir; use super::*; @@ -824,9 +835,9 @@ mod tests { assert_eq!(graph.node_count(), 4); // Verify packages were found - let mut packages_found = HashSet::::new(); + let mut packages_found = FxHashSet::::default(); for node in graph.node_weights() { - packages_found.insert(node.package_json.name.to_string()); + packages_found.insert(node.package_json.name.clone()); } assert!(packages_found.contains("npm-monorepo")); assert!(packages_found.contains("@myorg/shared")); @@ -918,9 +929,9 @@ mod tests { assert_eq!(graph.node_count(), 4); // Verify all packages were found - let mut packages_found = HashSet::::new(); + let mut packages_found = FxHashSet::::default(); for node in graph.node_weights() { - packages_found.insert(node.package_json.name.to_string()); + packages_found.insert(node.package_json.name.clone()); } assert!(packages_found.contains("yarn-monorepo")); assert!(packages_found.contains("core")); @@ -1005,9 +1016,9 @@ mod tests { let graph = discover_package_graph(temp_dir_path).unwrap(); // Check which packages were included - let mut packages_found = HashSet::::new(); + let mut packages_found = FxHashSet::::default(); for node in graph.node_weights() { - packages_found.insert(node.package_json.name.to_string()); + packages_found.insert(node.package_json.name.clone()); } assert!(packages_found.contains("npm-workspace-exclusions"), "Root should be included"); diff --git a/crates/vite_workspace/src/package.rs b/crates/vite_workspace/src/package.rs index e6c39abb..87e886cc 100644 --- a/crates/vite_workspace/src/package.rs +++ b/crates/vite_workspace/src/package.rs @@ -1,5 +1,4 @@ -use std::collections::HashMap; - +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use vite_str::Str; @@ -17,13 +16,13 @@ pub struct PackageJson { #[serde(default)] pub name: Str, #[serde(default)] - pub scripts: HashMap, + pub scripts: FxHashMap, #[serde(default)] - pub dependencies: HashMap, + pub dependencies: FxHashMap, #[serde(default)] - pub dev_dependencies: HashMap, + pub dev_dependencies: FxHashMap, #[serde(default)] - pub peer_dependencies: HashMap, + pub peer_dependencies: FxHashMap, } impl std::fmt::Debug for PackageJson { diff --git a/crates/vite_workspace/src/package_manager.rs b/crates/vite_workspace/src/package_manager.rs index 21110434..c30bf9a7 100644 --- a/crates/vite_workspace/src/package_manager.rs +++ b/crates/vite_workspace/src/package_manager.rs @@ -17,12 +17,18 @@ pub struct FileWithPath { impl FileWithPath { /// Open a file at the given path. + /// + /// # Errors + /// Returns an error if the file cannot be opened. pub fn open(path: Arc) -> Result { let file = File::open(&*path)?; Ok(Self { file, path }) } /// Try to open a file, returning None if it doesn't exist. + /// + /// # Errors + /// Returns an error if the file exists but cannot be opened. pub fn open_if_exists(path: Arc) -> Result, Error> { match File::open(&*path) { Ok(file) => Ok(Some(Self { file, path })), @@ -32,17 +38,19 @@ impl FileWithPath { } /// Get a reference to the file handle. - pub fn file(&self) -> &File { + #[must_use] + pub const fn file(&self) -> &File { &self.file } /// Get a mutable reference to the file handle. - pub fn file_mut(&mut self) -> &mut File { + pub const fn file_mut(&mut self) -> &mut File { &mut self.file } /// Get the file path. - pub fn path(&self) -> &Arc { + #[must_use] + pub const fn path(&self) -> &Arc { &self.path } } @@ -58,6 +66,12 @@ pub struct PackageRoot<'a> { /// Find the package root directory from the current working directory. `original_cwd` must be absolute. /// /// If the package.json file is not found, will return `PackageJsonNotFound` error. +/// +/// # Errors +/// Returns an error if no `package.json` is found in any ancestor directory, or if a path cannot be stripped. +/// +/// # Panics +/// Panics if `original_cwd` is not within the found package root (should not happen in practice). pub fn find_package_root(original_cwd: &AbsolutePath) -> Result, Error> { let mut cwd = original_cwd; loop { @@ -114,6 +128,12 @@ pub struct WorkspaceRoot { /// If the workspace file is not found, but a package is found, `workspace_file` will be `NonWorkspacePackage` with the `package.json` File. /// /// If neither workspace nor package is found, will return `PackageJsonNotFound` error. +/// +/// # Errors +/// Returns an error if no workspace or package is found, or if file I/O or JSON/YAML parsing fails. +/// +/// # Panics +/// Panics if `original_cwd` is not within the found workspace root (should not happen in practice). pub fn find_workspace_root( original_cwd: &AbsolutePath, ) -> Result<(WorkspaceRoot, RelativePathBuf), Error> { diff --git a/justfile b/justfile index 0a91b020..c465969b 100644 --- a/justfile +++ b/justfile @@ -41,6 +41,12 @@ test: lint: cargo clippy --workspace --all-targets --all-features -- --deny warnings +lint-linux: + cargo-zigbuild clippy --workspace --all-targets --all-features --target x86_64-unknown-linux-gnu -- --deny warnings + +lint-windows: + cargo-xwin clippy --workspace --all-targets --all-features --target x86_64-pc-windows-msvc -- --deny warnings + [unix] doc: RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items diff --git a/package.json b/package.json index 6096b09f..463cf405 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "type": "module", "scripts": { "prepare": "husky", - "hello": "echo Hello, Vite Task Monorepo!", + "hello": "bash -c pwd", "lint": "vp lint" }, "devDependencies": {