Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions crates/fspy_client_unix/.clippy.toml
27 changes: 27 additions & 0 deletions crates/fspy_client_unix/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions crates/fspy_client_unix/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) })
Expand Down Expand Up @@ -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;
}

Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,10 +25,12 @@ pub struct Client {
ipc_sender: Option<Sender>,
}

// 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 {}

Expand All @@ -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<Item = sigsafe::env::Entry>) -> Self {
use fspy_shared_unix::payload::decode_payload_from_env;

pub fn from_env(envs: impl Iterator<Item = sigsafe::env::Entry>) -> 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
}
Expand All @@ -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();
Expand All @@ -88,26 +94,54 @@ 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<R>(
&self,
config: ExecResolveConfig,
raw_exec: RawExec,
f: impl FnOnce(RawExec, Option<PreExec>) -> nix::Result<R>,
) -> nix::Result<R> {
// 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();
})?;
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 {
Expand All @@ -116,47 +150,3 @@ impl Client {
self.send(mode, Path::new(OsStr::from_bytes(abs_path.as_bytes())))
}
}

static CLIENT: OnceLock<Client> = 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<bool> = const { Cell::new(false) };
}

struct ResetHandling<'a>(&'a Cell<bool>);
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,22 @@ impl RawExec {
) -> Vec<T> {
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::<T>::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
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 1 addition & 7 deletions crates/fspy_preload_unix/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,15 @@ 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 }
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 }

Expand Down
Loading
Loading