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
14 changes: 14 additions & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ bitflags = "2.10.0"
brush-parser = "0.4.0"
bstr = { version = "1.12.0", default-features = false }
bump-scope = { version = "2", default-features = false, features = ["allocator-api2-02"] }
bumpalo = { version = "3.17.0", features = ["collections"] }
bumpalo = { version = "3.17.0", features = ["allocator-api2", "collections"] }
bytemuck = { version = "1.23.0", features = ["extern_crate_alloc", "must_cast"] }
cargo-platform = "=0.3.2"
cc = "1.2.39"
Expand Down Expand Up @@ -138,6 +138,7 @@ fspy_nostd = { path = "crates/fspy_nostd" }
fspy_nostd_alloc = { path = "crates/fspy_nostd_alloc" }
similar = "3.0.0"
smallvec = { version = "2.0.0-alpha.12", features = ["std"] }
static_cell = "2"
snapshot_test = { path = "crates/snapshot_test" }
socket_ipc = { path = "crates/socket_ipc" }
stackalloc = "1.2.1"
Expand Down
57 changes: 36 additions & 21 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ use fspy_seccomp_unotify::supervisor::supervise;
use fspy_shared::ipc::PathAccess;
#[cfg(not(target_env = "musl"))]
use fspy_shared::ipc::{IpcStr, channel::channel};
#[cfg(target_os = "macos")]
use fspy_shared_unix::payload::Artifacts;
use fspy_shared_unix::{
exec::ExecResolveConfig,
payload::{Payload, encode_payload},
Expand All @@ -29,9 +27,15 @@ use crate::ipc::ChannelAccesses;
use crate::{ChildTermination, Command, TrackedChild, arena::PathAccessArena, error::SpawnError};

#[derive(Debug)]
#[cfg_attr(
target_os = "macos",
expect(clippy::struct_field_names, reason = "each field names a distinct injected path")
)]
pub struct SpyImpl {
#[cfg(target_os = "macos")]
artifacts: Artifacts,
bash_path: Box<IpcStr>,
#[cfg(target_os = "macos")]
coreutils_path: Box<IpcStr>,

#[cfg(not(target_env = "musl"))]
preload_path: Box<IpcStr>,
Expand All @@ -58,15 +62,19 @@ impl SpyImpl {
#[cfg(not(target_env = "musl"))]
preload_path,
#[cfg(target_os = "macos")]
artifacts: {
let coreutils_path =
macos_artifacts::COREUTILS_BINARY.materialize().executable().at(dir)?;
let bash_path = macos_artifacts::OILS_BINARY.materialize().executable().at(dir)?;
Artifacts {
bash_path: bash_path.as_path().into(),
coreutils_path: coreutils_path.as_path().into(),
}
},
bash_path: macos_artifacts::OILS_BINARY
.materialize()
.executable()
.at(dir)?
.as_path()
.into(),
#[cfg(target_os = "macos")]
coreutils_path: macos_artifacts::COREUTILS_BINARY
.materialize()
.executable()
.at(dir)?
.as_path()
.into(),
})
}

Expand All @@ -79,25 +87,32 @@ impl SpyImpl {
let supervisor = supervise::<SyscallHandler>().map_err(SpawnError::Supervisor)?;

#[cfg(not(target_env = "musl"))]
let (ipc_channel_conf, ipc_receiver) =
channel(crate::ipc::shm_capacity(), allocator_api2::alloc::Global)
.map_err(SpawnError::ChannelCreation)?;
let ipc_receiver = channel(crate::ipc::shm_capacity(), allocator_api2::alloc::Global)
.map_err(SpawnError::ChannelCreation)?;

let payload = Payload {
#[cfg(not(target_env = "musl"))]
ipc_channel_conf,

#[cfg(target_os = "macos")]
artifacts: self.artifacts.clone(),
ipc_channel_conf: ipc_receiver.conf(),
#[cfg(target_env = "musl")]
ipc_channel_conf: core::marker::PhantomData,

#[cfg(not(target_env = "musl"))]
preload_path: self.preload_path.clone(),
preload_path: &self.preload_path,

#[cfg(target_os = "macos")]
artifacts: fspy_shared_unix::payload::Artifacts {
bash_path: &self.bash_path,
coreutils_path: &self.coreutils_path,
},

#[cfg(target_os = "linux")]
seccomp_payload: supervisor.payload().clone(),
};

let encoded_payload = encode_payload(payload);
// Spawn-scoped storage for the encoded payload, freed when this
// spawn returns.
let payload_bump = bumpalo::Bump::new();
let encoded_payload = encode_payload(payload, &payload_bump);

let mut exec = command.get_exec();
let mut exec_resolve_accesses = PathAccessArena::default();
Expand Down
7 changes: 3 additions & 4 deletions crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,8 @@ impl SpyImpl {

command.creation_flags(CREATE_SUSPENDED);

let (channel_conf, receiver) =
channel(crate::ipc::shm_capacity(), allocator_api2::alloc::Global)
.map_err(SpawnError::ChannelCreation)?;
let receiver = channel(crate::ipc::shm_capacity(), allocator_api2::alloc::Global)
.map_err(SpawnError::ChannelCreation)?;

let mut spawn_success = false;
let spawn_success = &mut spawn_success;
Expand All @@ -105,7 +104,7 @@ impl SpyImpl {
}

let payload = Payload {
channel_conf: channel_conf.clone(),
channel_conf: receiver.conf(),
ansi_dll_path_with_nul: ansi_dll_path_with_nul.to_bytes(),
};
let payload_bytes = wincode::serialize(&payload).unwrap();
Expand Down
38 changes: 23 additions & 15 deletions crates/fspy_client_unix/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,36 @@ use fspy_shared_unix::{
};
use raw_exec::RawExec;

pub struct Client {
encoded_payload: EncodedPayload,
pub struct Client<'a> {
encoded_payload: EncodedPayload<'a>,
ipc_sender: Option<Sender>,
}

// 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: 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 {}
// Seals the view-only design: the client holds views of leaked memory and
// the sender, never an allocator. A retained allocator handle is
// interior-mutable and would fail this assertion. (`Sender`'s own manual
// `Send`/`Sync` impls are the one audited exception the check trusts.)
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Client<'static>>();
};

impl Debug for Client {
impl Debug for Client<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client").finish()
}
}

impl Client {
/// Constructs a client from the encoded payload in the process environment.
impl<'a> Client<'a> {
/// Constructs a client from the encoded payload in the process
/// environment, leaking the payload's storage into `allocator`.
///
/// The sender's temporary path decode also comes from `allocator` and
/// drops before this returns; a bump allocator gets that space back,
/// since the block is its most recent allocation. `A: 'a` bounds the
/// client's lifetime — a `'static` allocator yields a `Client<'static>`
/// with no further ceremony — and the client never retains the
/// allocator itself (see the `Send + Sync` assertion above).
///
/// # Panics
///
Expand All @@ -50,9 +58,9 @@ impl Client {
/// [`ChannelConf::sender`](fspy_shared::ipc::channel::ChannelConf::sender)).
pub fn from_env(
envs: impl Iterator<Item = fspy_nostd::env::Entry>,
allocator: impl Allocator,
allocator: impl Allocator + Clone + 'a,
) -> Self {
let encoded_payload = decode_payload_from_env(envs).unwrap();
let encoded_payload = decode_payload_from_env(envs, allocator.clone()).unwrap();

// `None` when the channel is already over, which happens when this
// process starts after the root target exited. Nothing is said
Expand Down
12 changes: 0 additions & 12 deletions crates/fspy_ipc_str/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,6 @@ impl IpcStr {
Self::wrap_ref(bump.alloc_slice_copy(&self.data))
}

/// Copies this IPC string into a box.
#[must_use]
pub fn to_boxed(&self) -> Box<Self> {
Self::wrap_box(self.data.into())
}

/// Creates an IPC string that borrows the code units of `path`, without
/// its NUL terminator.
///
Expand Down Expand Up @@ -210,12 +204,6 @@ impl<'a, S: AsRef<OsStr> + ?Sized> From<&'a S> for &'a IpcStr {
}
}

impl Clone for Box<IpcStr> {
fn clone(&self) -> Self {
IpcStr::wrap_box(self.data.into())
}
}

impl<S: AsRef<OsStr>> From<S> for Box<IpcStr> {
#[cfg(unix)]
fn from(value: S) -> Self {
Expand Down
31 changes: 28 additions & 3 deletions crates/fspy_nostd_alloc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//! fixed-size chunks; and `bump_scope::Bump`s on top. [`pooled_bump`]
//! creates a bump that draws its chunks from the pool and returns them on
//! drop, so frequent short-lived bumps reuse memory instead of paying two
//! syscalls each.
//! syscalls each. [`page_bump`] bypasses the pool for bumps whose chunks
//! must never be recycled into other bumps.

#![cfg_attr(not(test), no_std)]

Expand All @@ -34,10 +35,10 @@ use bump_scope::{
};
pub use c_string::{CString, OsCString};
#[cfg(unix)]
pub(crate) use mmap::MmapAllocator as PageAllocator;
pub use mmap::MmapAllocator as PageAllocator;
use pool::ChunkPool;
#[cfg(windows)]
pub(crate) use virtual_alloc::VirtualAllocator as PageAllocator;
pub use virtual_alloc::VirtualAllocator as PageAllocator;

/// Every cached chunk is 64 KiB: a whole multiple of the page size on all
/// supported targets, and big enough that most intercepted calls fit their
Expand Down Expand Up @@ -87,6 +88,30 @@ impl Default for &'static ChunkPool<PageAllocator, CHUNK_SIZE, CHUNK_ALIGN, SLOT
}
}

/// A bump allocator drawing whole pages straight from the kernel — never
/// from the chunk pool — so its memory is never recycled into other bumps.
///
/// The concrete type is public so a caller can house one in static
/// storage; note that a bump is not [`Sync`], so a `static` needs a cell
/// that hands out access, and no safe code can retain a leaked handle
/// globally.
pub type PageBump = Bump<AllocatorApi2V02Compat<PageAllocator>, PageBumpSettings>;

/// [`PageBump`]'s settings: start without a chunk, so creating one
/// allocates nothing.
pub type PageBumpSettings = <BumpSettings as BumpAllocatorSettings>::WithGuaranteedAllocated<false>;

/// Creates an empty [`PageBump`].
///
/// Creating it allocates nothing; the first allocation maps one chunk, and
/// further chunks are mapped only if the data outgrows it. Dropping the
/// bump frees its chunks; leaking it instead makes its allocations
/// permanent.
#[must_use]
pub const fn page_bump() -> PageBump {
Bump::unallocated()
}

/// Creates a fresh bump backed by the process-wide chunk pool.
///
/// Creating the bump allocates nothing; the first allocation grabs a whole
Expand Down
1 change: 1 addition & 0 deletions crates/fspy_preload_unix/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ libc = { workspace = true }
nix = { workspace = true, features = ["signal", "fs", "socket", "mman", "time"] }
fspy_nostd = { workspace = true }
fspy_nostd_alloc = { workspace = true }
static_cell = { workspace = true }

[lints]
workspace = true
15 changes: 12 additions & 3 deletions crates/fspy_preload_unix/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{cell::Cell, sync::OnceLock};
use convert::{ToAbsolutePath, ToAccessMode};
pub use fspy_client_unix::{Client, convert, raw_exec};

static CLIENT: OnceLock<Client> = OnceLock::new();
static CLIENT: OnceLock<Client<'static>> = OnceLock::new();

// Resolving and reporting a file access can call another interposed function.
// Suppress same-thread re-entry to prevent recursive access handling while
Expand All @@ -19,7 +19,7 @@ impl Drop for ResetHandling<'_> {
}
}

pub fn global_client() -> Option<&'static Client> {
pub fn global_client() -> Option<&'static Client<'static>> {
CLIENT.get()
}

Expand All @@ -45,5 +45,14 @@ 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 { fspy_nostd::env::current() }.unwrap();
CLIENT.set(Client::from_env(current.envs(), fspy_nostd_alloc::pooled_bump())).unwrap();
// The attach's storage: one page-backed bump housed in a static, so
// its borrow is 'static by construction and the client comes out as
// Client<'static> with no lifetime promotion anywhere. The bump is not
// Sync, so this handle cannot be stored globally by any safe code, and
// the Send/Sync assertion on Client proves the client keeps no handle.
static BUMP: static_cell::StaticCell<fspy_nostd_alloc::PageBump> =
static_cell::StaticCell::new();
let bump: &'static fspy_nostd_alloc::PageBump = BUMP.init(fspy_nostd_alloc::page_bump());
let client = Client::from_env(current.envs(), bump);
CLIENT.set(client).unwrap();
}
10 changes: 6 additions & 4 deletions crates/fspy_preload_windows/src/windows/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use winapi::{shared::minwindef::BOOL, um::winnt::HANDLE};

pub struct Client<'a> {
payload: Payload<'a>,
payload_bytes: &'a [u8],
ipc_sender: Option<Sender>,
}

Expand All @@ -23,7 +24,7 @@ impl<'a> Client<'a> {
// corrupts whatever that process is printing.
let ipc_sender = payload.channel_conf.sender(allocator);

Self { payload, ipc_sender }
Self { payload, payload_bytes, ipc_sender }
}

pub fn send(&self, access: PathAccess<'_>) {
Expand All @@ -36,14 +37,15 @@ impl<'a> Client<'a> {
}

pub unsafe fn prepare_child_process(&self, child_handle: HANDLE) -> BOOL {
let payload_bytes = wincode::serialize(&self.payload).unwrap();
// The payload propagates to children unchanged, so forward the bytes
// this process was given instead of re-serializing.
// SAFETY: FFI call to DetourCopyPayloadToProcess with valid handle and payload buffer
unsafe {
DetourCopyPayloadToProcess(
child_handle,
&PAYLOAD_ID,
payload_bytes.as_ptr().cast(),
payload_bytes.len().try_into().unwrap(),
self.payload_bytes.as_ptr().cast(),
self.payload_bytes.len().try_into().unwrap(),
)
}
}
Expand Down
Loading
Loading