diff --git a/Cargo.lock b/Cargo.lock index 5fcb11c0..1fdcbf68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1287,6 +1287,23 @@ version = "0.0.0" name = "fspy_benchmark_target" version = "0.0.0" +[[package]] +name = "fspy_client_unix" +version = "0.0.0" +dependencies = [ + "allocator-api2", + "anyhow", + "bstr", + "fspy_shared", + "fspy_shared_unix", + "itoa", + "libc", + "nix 0.31.2", + "sigsafe", + "sigsafe_alloc", + "wincode", +] + [[package]] name = "fspy_detours_sys" version = "0.0.0" @@ -1313,19 +1330,15 @@ dependencies = [ name = "fspy_preload_unix" version = "0.0.0" dependencies = [ - "allocator-api2", - "anyhow", "artifact_profile", - "bstr", "ctor", + "fspy_client_unix", "fspy_shared", "fspy_shared_unix", - "itoa", "libc", "nix 0.31.2", "sigsafe", "sigsafe_alloc", - "wincode", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 06f70909..e5353f2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ flate2 = "1.0.35" fspy = { path = "crates/fspy" } fspy_benchmark_launcher = { path = "crates/fspy_benchmark_launcher", artifact = "bin" } fspy_benchmark_target = { path = "crates/fspy_benchmark_target", artifact = "bin" } +fspy_client_unix = { path = "crates/fspy_client_unix" } fspy_detours_sys = { path = "crates/fspy_detours_sys" } fspy_preload_unix = { path = "crates/fspy_preload_unix", artifact = "cdylib", target = "target" } fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdylib", target = "target" } diff --git a/crates/fspy_client_unix/.clippy.toml b/crates/fspy_client_unix/.clippy.toml new file mode 120000 index 00000000..c7929b36 --- /dev/null +++ b/crates/fspy_client_unix/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/fspy_client_unix/Cargo.toml b/crates/fspy_client_unix/Cargo.toml new file mode 100644 index 00000000..46dab3b2 --- /dev/null +++ b/crates/fspy_client_unix/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "fspy_client_unix" +edition = "2024" +license.workspace = true +publish = false + +[target.'cfg(all(unix, not(target_env = "musl")))'.dependencies] +allocator-api2 = { workspace = true, features = ["alloc"] } +anyhow = { workspace = true } +bstr = { workspace = true, features = ["alloc", "std"] } +fspy_shared = { workspace = true } +fspy_shared_unix = { workspace = true } +libc = { workspace = true } +nix = { workspace = true, features = ["fs"] } +sigsafe = { workspace = true } +sigsafe_alloc = { workspace = true } +wincode = { workspace = true } + +[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies] +itoa = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/fspy_client_unix/README.md b/crates/fspy_client_unix/README.md new file mode 100644 index 00000000..6839cc9f --- /dev/null +++ b/crates/fspy_client_unix/README.md @@ -0,0 +1,8 @@ +# fspy_client_unix + +The Unix client that resolves and reports file accesses to the fspy supervisor. + +The preload library owns interception-specific initialization and re-entry +guards. This crate owns the reusable client, path conversion, and exec +transformation so another Unix injection mechanism can provide its own runtime +integration around the same client. diff --git a/crates/fspy_preload_unix/src/client/convert.rs b/crates/fspy_client_unix/src/convert.rs similarity index 93% rename from crates/fspy_preload_unix/src/client/convert.rs rename to crates/fspy_client_unix/src/convert.rs index 9d74d6d4..be7a86e9 100644 --- a/crates/fspy_preload_unix/src/client/convert.rs +++ b/crates/fspy_client_unix/src/convert.rs @@ -75,6 +75,11 @@ pub trait ToAbsolutePath { /// [`as_bytes`] gives the path without the NUL. /// /// [`as_bytes`]: sigsafe::CStr::as_bytes + /// + /// # Errors + /// + /// Returns the error reported while resolving a descriptor or the current + /// working directory. fn to_absolute_path<'a, A: Allocator>( self, allocator: &'a A, @@ -111,6 +116,7 @@ impl PathAt<'_, '_> { /// /// `fd` must remain valid while the returned value is used, and `path` /// must point to a valid NUL-terminated string. + #[must_use] pub const unsafe fn borrow_raw(fd: c_int, path: *const c_char) -> Self { // SAFETY: both invariants are upheld by the caller. Self(unsafe { BorrowedFd::borrow_raw(fd) }, unsafe { sigsafe::CStr::from_ptr(path) }) @@ -164,6 +170,12 @@ impl ToAbsolutePath for sigsafe::CStr<'_, sigsafe::Thin> { } pub trait ToAccessMode { + /// Converts the intercepted operation's mode into an access mode. + /// + /// # Safety + /// + /// Implementations backed by raw process pointers require those pointers + /// to remain valid for the conversion. unsafe fn to_access_mode(self) -> AccessMode; } @@ -187,7 +199,8 @@ 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 + // 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_client_unix/src/lib.rs similarity index 57% rename from crates/fspy_preload_unix/src/client/mod.rs rename to crates/fspy_client_unix/src/lib.rs index 582e857f..58dbd042 100644 --- a/crates/fspy_preload_unix/src/client/mod.rs +++ b/crates/fspy_client_unix/src/lib.rs @@ -1,16 +1,20 @@ +//! Reusable Unix client for reporting file accesses to the fspy supervisor. + +// Compile as an empty crate on non-Unix targets and on musl, matching the +// current preload client. Musl support will be enabled as its dependencies are +// made usable before libc initialization. +#![cfg(all(unix, not(target_env = "musl")))] + pub mod convert; pub mod raw_exec; -use std::{ - cell::Cell, ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, - path::Path, sync::OnceLock, -}; +use std::{ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path}; use convert::{ToAbsolutePath, ToAccessMode}; use fspy_shared::ipc::{PathAccess, channel::Sender}; use fspy_shared_unix::{ exec::ExecResolveConfig, - payload::EncodedPayload, + payload::{EncodedPayload, decode_payload_from_env}, spawn::{PreExec, handle_exec}, }; use raw_exec::RawExec; @@ -21,10 +25,12 @@ pub struct Client { ipc_sender: Option, } -// SAFETY: Client fields are only mutated during initialization in the ctor; after that, all access is read-only +// SAFETY: construction owns every field, later methods borrow them immutably, +// and the sender synchronizes its shared-memory access. #[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 +// SAFETY: ownership of every field can move with the client, and the sender +// synchronizes its shared-memory access. #[cfg(target_os = "macos")] unsafe impl Send for Client {} @@ -35,22 +41,23 @@ impl Debug for Client { } impl Client { + /// Constructs a client from the encoded payload in the process environment. + /// + /// # Panics + /// + /// Panics when the payload is missing, malformed, or cannot be decoded. #[expect( clippy::print_stderr, - reason = "preload library intentionally uses stderr for error reporting" + reason = "the client intentionally reports an unavailable supervisor channel" )] - #[cfg(not(test))] - fn from_env(envs: impl Iterator) -> Self { - use fspy_shared_unix::payload::decode_payload_from_env; - + pub fn from_env(envs: impl Iterator) -> Self { let encoded_payload = decode_payload_from_env(envs).unwrap(); let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), Err(err) => { - // 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. + // This can happen if the process starts after the root target + // has exited and the receiver has closed the channel. eprintln!("fspy: failed to create ipc sender: {err}"); None } @@ -61,7 +68,6 @@ impl Client { fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) -> anyhow::Result<()> { let Some(ipc_sender) = &self.ipc_sender else { - // ipc channel not available, skip sending return Ok(()); }; let path_bytes = path.as_os_str().as_bytes(); @@ -88,13 +94,30 @@ impl Client { Ok(()) } + /// Resolves and reports an exec before forwarding its transformed arguments. + /// + /// # Safety + /// + /// `raw_exec` must contain the valid C strings and pointer arrays required + /// by [`RawExec::to_exec`]. The callback must not retain pointers from the + /// transformed `RawExec` after it returns. + /// + /// # Errors + /// + /// Returns errors from exec resolution, platform preparation, or the + /// forwarding callback. + /// + /// # Panics + /// + /// Panics if reporting the executable path fails. pub unsafe fn handle_exec( &self, config: ExecResolveConfig, 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 + // SAFETY: raw_exec contains valid pointers to C strings and + // null-terminated arrays, as provided by the caller. let mut exec = unsafe { raw_exec.to_exec() }; let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| { self.send(mode, path).unwrap(); @@ -102,12 +125,23 @@ impl Client { RawExec::from_exec(exec, |raw_command| f(raw_command, pre_exec)) } + /// Resolves and reports one intercepted file access. + /// + /// # Safety + /// + /// `path` and `mode` must satisfy their implementation-specific raw + /// pointer and descriptor contracts for the duration of this call. + /// + /// # Errors + /// + /// Returns errors from path resolution or shared-memory serialization. pub unsafe fn try_handle_open( &self, 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 + // SAFETY: mode contains a valid pointer (if ModeStr) or a plain value, + // as provided by the caller. let mode = unsafe { mode.to_access_mode() }; let arena = sigsafe_alloc::arena(); let Some(abs_path) = path.to_absolute_path(&arena)? else { @@ -116,47 +150,3 @@ impl Client { self.send(mode, Path::new(OsStr::from_bytes(abs_path.as_bytes()))) } } - -static CLIENT: OnceLock = OnceLock::new(); - -// Resolving and reporting a file access can call another interposed function. -// Suppress same-thread re-entry to prevent recursive access handling while -// still recording accesses from other threads. -thread_local! { - static HANDLING_OPEN: Cell = const { Cell::new(false) }; -} - -struct ResetHandling<'a>(&'a Cell); -impl Drop for ResetHandling<'_> { - fn drop(&mut self) { - self.0.set(false); - } -} - -pub fn global_client() -> Option<&'static Client> { - CLIENT.get() -} - -pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) { - HANDLING_OPEN.with(|handling| { - if handling.replace(true) { - return; - } - - let _reset = ResetHandling(handling); - - 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(); - } - }); -} - -#[cfg(not(test))] -#[ctor::ctor(unsafe)] -fn init_client() { - // SAFETY: the ctor only reads the process environment while constructing - // the client and does not retain borrowed environment views. - let current = unsafe { sigsafe::env::current() }.unwrap(); - CLIENT.set(Client::from_env(current.envs())).unwrap(); -} diff --git a/crates/fspy_preload_unix/src/client/raw_exec.rs b/crates/fspy_client_unix/src/raw_exec.rs similarity index 83% rename from crates/fspy_preload_unix/src/client/raw_exec.rs rename to crates/fspy_client_unix/src/raw_exec.rs index 44ef8d35..fb216352 100644 --- a/crates/fspy_preload_unix/src/client/raw_exec.rs +++ b/crates/fspy_client_unix/src/raw_exec.rs @@ -17,18 +17,22 @@ 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) + // 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 + // 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 + // 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) + // 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 @@ -59,14 +63,23 @@ impl RawExec { f(ptr_vec.as_ptr()) } + /// Copies the raw exec arguments into owned Rust values. + /// + /// # Safety + /// + /// `prog` must point to a valid C string. `argv` and `envp` must each + /// point to a readable, null-terminated array of valid C string pointers. 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 + // 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 + // 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 + // 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| { env.iter().position(|b| *b == b'=').map_or_else( diff --git a/crates/fspy_preload_unix/Cargo.toml b/crates/fspy_preload_unix/Cargo.toml index c274e14f..f5bbd228 100644 --- a/crates/fspy_preload_unix/Cargo.toml +++ b/crates/fspy_preload_unix/Cargo.toml @@ -8,11 +8,8 @@ publish = false crate-type = ["cdylib"] [target.'cfg(unix)'.dependencies] -allocator-api2 = { workspace = true, features = ["alloc"] } -anyhow = { workspace = true } -wincode = { workspace = true } -bstr = { workspace = true, features = ["alloc", "std"] } ctor = { workspace = true } +fspy_client_unix = { workspace = true } fspy_shared = { workspace = true } fspy_shared_unix = { workspace = true } libc = { workspace = true } @@ -20,9 +17,6 @@ nix = { workspace = true, features = ["signal", "fs", "socket", "mman", "time"] sigsafe = { workspace = true } sigsafe_alloc = { workspace = true } -[target.'cfg(target_os = "linux")'.dependencies] -itoa = { workspace = true } - [build-dependencies] artifact_profile = { workspace = true } diff --git a/crates/fspy_preload_unix/src/client.rs b/crates/fspy_preload_unix/src/client.rs new file mode 100644 index 00000000..ab9525c7 --- /dev/null +++ b/crates/fspy_preload_unix/src/client.rs @@ -0,0 +1,49 @@ +use std::{cell::Cell, sync::OnceLock}; + +use convert::{ToAbsolutePath, ToAccessMode}; +pub use fspy_client_unix::{Client, convert, raw_exec}; + +static CLIENT: OnceLock = OnceLock::new(); + +// Resolving and reporting a file access can call another interposed function. +// Suppress same-thread re-entry to prevent recursive access handling while +// still recording accesses from other threads. +thread_local! { + static HANDLING_OPEN: Cell = const { Cell::new(false) }; +} + +struct ResetHandling<'a>(&'a Cell); +impl Drop for ResetHandling<'_> { + fn drop(&mut self) { + self.0.set(false); + } +} + +pub fn global_client() -> Option<&'static Client> { + CLIENT.get() +} + +pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) { + HANDLING_OPEN.with(|handling| { + if handling.replace(true) { + return; + } + + let _reset = ResetHandling(handling); + + 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(); + } + }); +} + +#[cfg(not(test))] +#[ctor::ctor(unsafe)] +fn init_client() { + // SAFETY: the ctor only reads the process environment while constructing + // the client and does not retain borrowed environment views. + let current = unsafe { sigsafe::env::current() }.unwrap(); + CLIENT.set(Client::from_env(current.envs())).unwrap(); +}