diff --git a/Cargo.lock b/Cargo.lock index bceabfee1..f0549dd6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1335,8 +1335,11 @@ dependencies = [ name = "fspy_ipc_str" version = "0.0.0" dependencies = [ + "allocator-api2", "bumpalo", "bytemuck", + "fspy_nostd", + "fspy_nostd_alloc", "wincode", ] diff --git a/crates/fspy_ipc_str/Cargo.toml b/crates/fspy_ipc_str/Cargo.toml index d11091914..79fcfba00 100644 --- a/crates/fspy_ipc_str/Cargo.toml +++ b/crates/fspy_ipc_str/Cargo.toml @@ -7,8 +7,11 @@ publish = false rust-version.workspace = true [dependencies] +allocator-api2 = { workspace = true, features = ["alloc"] } bumpalo = { workspace = true } bytemuck = { workspace = true, features = ["must_cast", "derive"] } +fspy_nostd = { workspace = true } +fspy_nostd_alloc = { workspace = true } wincode = { workspace = true } [lints] diff --git a/crates/fspy_ipc_str/src/lib.rs b/crates/fspy_ipc_str/src/lib.rs index e7773a640..691707897 100644 --- a/crates/fspy_ipc_str/src/lib.rs +++ b/crates/fspy_ipc_str/src/lib.rs @@ -8,10 +8,13 @@ use std::os::windows::ffi::OsStrExt as _; use std::os::windows::ffi::OsStringExt as _; use std::{borrow::Cow, ffi::OsStr, fmt::Debug, mem::MaybeUninit}; +use allocator_api2::alloc::Allocator; use bumpalo::Bump; #[cfg(windows)] use bytemuck::must_cast_slice; use bytemuck::{TransparentWrapper, TransparentWrapperAlloc}; +use fspy_nostd::{Fat, OsCStr}; +use fspy_nostd_alloc::OsCString; use wincode::{ SchemaRead, SchemaWrite, config::Config, @@ -90,6 +93,57 @@ impl IpcStr { pub fn clone_in<'bump>(&self, bump: &'bump Bump) -> &'bump Self { 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::wrap_box(self.data.into()) + } + + /// Creates an IPC string that borrows the code units of `path`, without + /// its NUL terminator. + /// + /// This is the inverse of [`to_os_c_string_in`](Self::to_os_c_string_in); + /// neither direction goes through [`OsStr`], so both work without std. + #[must_use] + pub fn from_os_c_str(path: OsCStr<'_, Fat>) -> &Self { + #[cfg(unix)] + return Self::wrap_ref(path.as_units()); + #[cfg(windows)] + return Self::wrap_ref(must_cast_slice(path.as_units())); + } + + /// Decodes this IPC string into an owned NUL-terminated platform C + /// string allocated in `allocator`. + /// + /// Returns [`None`] when the contents cannot name a path: an odd byte + /// length on Windows, or an interior NUL code unit. + #[must_use] + pub fn to_os_c_string_in(&self, allocator: A) -> Option> { + #[cfg(unix)] + { + let mut units = + allocator_api2::vec::Vec::with_capacity_in(self.data.len() + 1, allocator); + units.extend_from_slice(&self.data); + units.push(0); + OsCString::from_vec_with_nul(units) + } + #[cfg(windows)] + { + if !self.data.len().is_multiple_of(2) { + return None; + } + let len = self.data.len() / 2; + let mut units = allocator_api2::vec::Vec::with_capacity_in(len + 1, allocator); + units.resize(len, 0); + // The destination is aligned `u16` storage; viewing it as bytes + // sidesteps the source's unspecified alignment (see the field + // docs). + bytemuck::must_cast_slice_mut::(&mut units).copy_from_slice(&self.data); + units.push(0); + OsCString::from_vec_with_nul(units) + } + } } impl Debug for IpcStr { diff --git a/crates/fspy_nostd/src/mm/windows.rs b/crates/fspy_nostd/src/mm/windows.rs index 60df93f42..df2ce79a1 100644 --- a/crates/fspy_nostd/src/mm/windows.rs +++ b/crates/fspy_nostd/src/mm/windows.rs @@ -1,9 +1,13 @@ -use core::{ffi::c_void, ptr}; +use core::{ + ffi::c_void, + ptr::{self, NonNull}, +}; use bitflags::bitflags; use windows_sys::Win32::System::Memory::{ - CreateFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, - PAGE_READWRITE, UnmapViewOfFile, + CreateFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, + MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, VirtualAlloc, + VirtualFree, }; use crate::{BorrowedHandle, OwnedHandle, Result, SecurityAttributes}; @@ -28,6 +32,51 @@ bitflags! { /// Permit writes to the view. const WRITE = FILE_MAP_WRITE; } + + /// How `VirtualAlloc` claims address space. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct AllocationType: u32 { + /// Reserve address space. + const RESERVE = MEM_RESERVE; + /// Commit pages within reserved address space. + const COMMIT = MEM_COMMIT; + } +} + +/// Calls `VirtualAlloc` at a system-chosen address and returns the new +/// region's base. +/// +/// Committed pages read as zero until written. +/// +/// # Errors +/// +/// Returns the error reported by `VirtualAlloc`. +pub fn virtual_alloc( + size: usize, + allocation_type: AllocationType, + protection: PageProtection, +) -> Result> { + // SAFETY: a system-chosen address cannot alias an existing allocation, + // and Windows validates the size, allocation type, and protection. + let address = + unsafe { VirtualAlloc(ptr::null(), size, allocation_type.bits(), protection as u32) }; + NonNull::new(address).ok_or_else(crate::windows::last_error) +} + +/// Calls `VirtualFree` with `MEM_RELEASE`, releasing the whole region. +/// +/// # Errors +/// +/// Returns the error reported by `VirtualFree`. +/// +/// # Safety +/// +/// `address` must be the base of a region returned by [`virtual_alloc`] that +/// has not been released, with no live references into it. +pub unsafe fn virtual_free(address: NonNull) -> Result<()> { + // SAFETY: the caller guarantees that `address` is an unreleased region + // base; releasing passes zero as the size. + crate::windows::bool_result(unsafe { VirtualFree(address.as_ptr(), 0, MEM_RELEASE) }) } /// An owned view of a file-mapping object. diff --git a/crates/fspy_nostd_alloc/Cargo.toml b/crates/fspy_nostd_alloc/Cargo.toml index 3c5e55cd8..ba2d36f86 100644 --- a/crates/fspy_nostd_alloc/Cargo.toml +++ b/crates/fspy_nostd_alloc/Cargo.toml @@ -9,10 +9,8 @@ doctest = false [dependencies] allocator-api2 = { workspace = true, features = ["alloc"] } -fspy_nostd = { workspace = true } - -[target.'cfg(unix)'.dependencies] bump-scope = { workspace = true } +fspy_nostd = { workspace = true } [lints] workspace = true diff --git a/crates/fspy_nostd_alloc/src/lib.rs b/crates/fspy_nostd_alloc/src/lib.rs index cbf04000e..98fabbe80 100644 --- a/crates/fspy_nostd_alloc/src/lib.rs +++ b/crates/fspy_nostd_alloc/src/lib.rs @@ -3,8 +3,9 @@ //! Taking malloc's lock is the classic way for interposed code to deadlock a //! traced program (see the crate docs), so the preload library allocates //! through this module instead. It stacks three layers and exposes only the -//! top one, [`arena`]. `MmapAllocator` is the bottom: a stateless allocator -//! where every allocation is a fresh anonymous mapping from [`fspy_nostd::mm`]. +//! top one, [`arena`]. A stateless page allocator is the bottom: every +//! allocation is fresh pages from [`fspy_nostd::mm`] — an anonymous mapping +//! on Unix, a `VirtualAlloc` region on Windows. //! `ChunkPool` sits on top of it and caches fixed-size chunks, so that //! frequent short tracing calls can reuse memory instead of paying two //! syscalls per call. [`arena`] creates one `bump_scope::Bump` per @@ -18,12 +19,11 @@ mod c_string; pub mod fs; #[cfg(unix)] mod mmap; -#[cfg(unix)] mod pool; +#[cfg(windows)] +mod virtual_alloc; -#[cfg(unix)] use allocator_api2::alloc::Allocator; -#[cfg(unix)] use bump_scope::{ Bump, alloc::compat::AllocatorApi2V02Compat, @@ -31,33 +31,30 @@ use bump_scope::{ }; pub use c_string::{CString, OsCString}; #[cfg(unix)] -use mmap::MmapAllocator; -#[cfg(unix)] +pub(crate) use mmap::MmapAllocator as PageAllocator; use pool::ChunkPool; +#[cfg(windows)] +pub(crate) 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 /// allocations into a single chunk. [`ArenaSettings`] pins the arenas' own /// chunk sizing to this same value. -#[cfg(unix)] const CHUNK_SIZE: usize = 64 * 1024; /// The alignment chunks are allocated with. Must be at least the alignment /// `bump_scope::Bump` uses for its chunk requests — 16 (see -/// [`MmapAllocator`]'s docs for the links). bump-scope does not export that +/// `MmapAllocator`'s docs for the links). bump-scope does not export that /// constant, so the `bump_chunk_requests_fit_the_pool_gates` test pins the /// fit instead: it fails if a bump-scope upgrade ever requests chunks the /// pool would refuse. -#[cfg(unix)] const CHUNK_ALIGN: usize = 16; /// At most this many chunks stay cached, capping retained memory at /// `SLOTS * CHUNK_SIZE` = 4 MiB. -#[cfg(unix)] const SLOTS: usize = 64; /// The process-wide chunk pool. `const`-initialized, so it works from the /// first allocation on — even before any constructor has run. -#[cfg(unix)] -static CHUNK_POOL: ChunkPool = ChunkPool::new(); +static CHUNK_POOL: ChunkPool = ChunkPool::new(); /// The `Bump` settings the arenas use — the defaults, with two changes: /// @@ -73,7 +70,6 @@ static CHUNK_POOL: ChunkPool = Ch /// minimum — which is exactly what keeps a minimum-sized request within /// the pool's `size <= CHUNK_SIZE` gate; the /// `bump_chunk_requests_fit_the_pool_gates` test pins that fit. -#[cfg(unix)] type ArenaSettings = <::WithGuaranteedAllocated as BumpAllocatorSettings>::WithMinimumChunkSize; /// `Bump::unallocated` requires its base allocator to implement `Default` @@ -81,8 +77,7 @@ type ArenaSettings = <::WithGuaranteedAll /// conjures one on first use). Point defaulted references at the /// process-wide pool. As an allocator, `&ChunkPool` already works through /// allocator-api2's blanket `impl Allocator for &A`. -#[cfg(unix)] -impl Default for &'static ChunkPool { +impl Default for &'static ChunkPool { fn default() -> Self { &CHUNK_POOL } @@ -99,18 +94,18 @@ impl Default for &'static ChunkPool impl Allocator { Bump::< - AllocatorApi2V02Compat<&'static ChunkPool>, + AllocatorApi2V02Compat<&'static ChunkPool>, ArenaSettings, >::unallocated() } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use core::alloc::Layout; diff --git a/crates/fspy_nostd_alloc/src/pool.rs b/crates/fspy_nostd_alloc/src/pool.rs index 3dcf31573..5eb663439 100644 --- a/crates/fspy_nostd_alloc/src/pool.rs +++ b/crates/fspy_nostd_alloc/src/pool.rs @@ -8,19 +8,19 @@ use core::{ use allocator_api2::alloc::{AllocError, Allocator}; -use super::mmap::MmapAllocator; +use super::PageAllocator; /// A fixed-size cache of memory chunks. /// /// Sits between the bump arenas and the underlying allocator `A` — -/// [`MmapAllocator`] in real use, any allocator in tests — so the chunks +/// the platform `PageAllocator` in real use, any allocator in tests — so the chunks /// that a finished arena gives back reach the next arena without going back /// to the kernel. /// /// The parameters are chosen by the layer above (the arena layer): every /// cached chunk is exactly `CHUNK_SIZE` bytes and allocated with /// `CHUNK_ALIGN`, which is also the strictest alignment the pool accepts -/// (`bump_scope::Bump` requests 16 — see [`MmapAllocator`]'s docs for the +/// (`bump_scope::Bump` requests 16 — see `MmapAllocator`'s docs for the /// links). The cache holds at most `SLOTS` chunks, so at most /// `SLOTS * CHUNK_SIZE` bytes stay retained. /// @@ -45,7 +45,7 @@ use super::mmap::MmapAllocator; /// lists to leave half-linked, so a thread that disappears mid-operation /// (`fork`, a signal) can strand at most the one chunk it was holding, /// never the pool. (The underlying allocator must give the same guarantee; -/// [`MmapAllocator`] does.) +/// both platform page allocators do.) /// /// The pool is `const`-constructible, so it can live in a `static` and /// work before anything else has run. @@ -60,11 +60,11 @@ pub struct ChunkPool< } impl - ChunkPool + ChunkPool { #[must_use] pub const fn new() -> Self { - Self::new_in(MmapAllocator) + Self::new_in(PageAllocator) } } @@ -346,8 +346,8 @@ mod tests { /// runs against `Global` so Miri can check it. #[test] #[cfg(not(miri))] - fn mmap_backed_pool_smoke() { - let pool = ChunkPool::::new(); + fn kernel_backed_pool_smoke() { + let pool = ChunkPool::::new(); let block = pool.allocate(layout(100)).unwrap(); assert_eq!(block.len(), CHUNK_SIZE); let addr = block.cast::().as_ptr().addr(); diff --git a/crates/fspy_nostd_alloc/src/virtual_alloc.rs b/crates/fspy_nostd_alloc/src/virtual_alloc.rs new file mode 100644 index 000000000..cdaf492b9 --- /dev/null +++ b/crates/fspy_nostd_alloc/src/virtual_alloc.rs @@ -0,0 +1,116 @@ +//! Page-granularity allocator backed by private virtual-memory regions. + +use core::{alloc::Layout, ptr::NonNull}; + +use allocator_api2::alloc::{AllocError, Allocator}; +use fspy_nostd::mm::{AllocationType, PageProtection, virtual_alloc, virtual_free}; + +/// The Windows page size on every supported architecture. +const PAGE_SIZE: usize = 4096; + +/// A stateless allocator: every allocation is a fresh private region from +/// `VirtualAlloc` and every deallocation a `VirtualFree` release. +/// +/// The Windows counterpart of the Unix `MmapAllocator`, with the same +/// guarantee for the same reason: it holds no state at all — no locks, no +/// free lists, no thread-locals — and its two calls are kernel calls that +/// never take the CRT heap lock, so it works under the loader lock and in +/// every other context where the process heap must not be touched. +/// +/// # What it accepts +/// +/// The request profile matches `MmapAllocator`: any non-zero size, and any +/// alignment up to the page size. Regions are reserved at allocation +/// granularity (64 KiB), so every served alignment holds. Zero-sized +/// layouts and over-page alignments are refused with [`AllocError`]. +/// +/// # Cost +/// +/// Every allocation reserves a fresh region and commits whole pages, and +/// every deallocation releases the region. Like its Unix counterpart, this +/// allocator is meant to sit below a chunk pool and bump arenas, not to +/// serve small allocations directly. +#[derive(Clone, Copy, Debug, Default)] +pub struct VirtualAllocator; + +// SAFETY: returned blocks are non-null, allocation-granularity-aligned (at +// least `layout.align()` for every served layout), at least `layout.size()` +// bytes large (the returned length reports the committed size), stay valid +// until deallocated, and distinct regions never overlap. +unsafe impl Allocator for VirtualAllocator { + fn allocate(&self, layout: Layout) -> Result, AllocError> { + // Outside the served request profile (see the type docs): refuse + // rather than carry an over-alignment or dangling-block code path. + if layout.size() == 0 || layout.align() > PAGE_SIZE { + return Err(AllocError); + } + // `checked_next_multiple_of` is total: `None` on overflow, which is + // already impossible — `Layout` caps sizes at `isize::MAX`. + let size = layout.size().checked_next_multiple_of(PAGE_SIZE).ok_or(AllocError)?; + let address = virtual_alloc( + size, + AllocationType::RESERVE | AllocationType::COMMIT, + PageProtection::ReadWrite, + ) + .map_err(|_| AllocError)?; + Ok(NonNull::slice_from_raw_parts(address.cast::(), size)) + } + + fn allocate_zeroed(&self, layout: Layout) -> Result, AllocError> { + // Freshly committed pages already read as zero. + self.allocate(layout) + } + + unsafe fn deallocate(&self, ptr: NonNull, _layout: Layout) { + // Releasing frees the whole region from its base, so the layout is + // not needed to size the call. + // SAFETY: caller contract — `ptr` was returned by `allocate`, making + // it an unreleased region base, and the block is no longer in use. + let _ = unsafe { virtual_free(ptr.cast()) }; + } +} + +#[cfg(all(test, not(miri)))] +mod tests { + use super::*; + + #[test] + fn blocks_are_aligned_zeroed_and_page_rounded() { + for (size, align) in [(1, 1), (100, 64), (4096, 4096), (5 << 20, 8)] { + let layout = Layout::from_size_align(size, align).unwrap(); + let block = VirtualAllocator.allocate(layout).unwrap(); + assert!(block.len() >= size, "size {size}"); + assert_eq!(block.len() % PAGE_SIZE, 0); + assert_eq!(block.cast::().as_ptr().addr() % align, 0, "align {align}"); + for i in 0..block.len() { + // SAFETY: fresh exclusive block of `block.len()` bytes. + assert_eq!(unsafe { block.cast::().as_ptr().add(i).read() }, 0); + } + // SAFETY: fresh exclusive block of at least `size` bytes. + unsafe { block.cast::().as_ptr().write_bytes(0x5A, size) }; + // SAFETY: allocated above; the layout fits the block. + unsafe { VirtualAllocator.deallocate(block.cast(), layout) }; + } + } + + #[test] + fn deallocate_accepts_any_fitting_size() { + let requested = Layout::from_size_align(100, 8).unwrap(); + let block = VirtualAllocator.allocate(requested).unwrap(); + // Deallocate with the *returned* size instead of the requested one — + // both are within the fit range the contract allows. + let fitting = Layout::from_size_align(block.len(), 8).unwrap(); + // SAFETY: allocated above; `fitting` is within the block's fit range. + unsafe { VirtualAllocator.deallocate(block.cast(), fitting) }; + } + + #[test] + fn out_of_profile_requests_are_refused() { + // Zero-sized and over-page-aligned layouts are outside the served + // request profile and must fail cleanly, not misbehave. + let zero = Layout::from_size_align(0, 16).unwrap(); + assert!(VirtualAllocator.allocate(zero).is_err()); + let over_aligned = Layout::from_size_align(64, 1 << 24).unwrap(); + assert!(VirtualAllocator.allocate(over_aligned).is_err()); + } +} diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 9cad595b2..ad5c5c205 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -37,8 +37,7 @@ 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())); - let shm_path = shm_backing_path()?; - let shm_c_path = os_c_string(shm_path.as_os_str())?; + let shm_c_path = os_c_string(shm_backing_path()?.as_os_str())?; let handle = fspy_shm::create(shm_c_path.as_c_str().as_thin(), capacity).map_err(shm_error_to_io)?; // The keeper exists from here on, so every error path below cleans up. @@ -47,7 +46,7 @@ pub fn channel(capacity: usize) -> io::Result<(ChannelConf, Receiver)> { let conf = ChannelConf { lock_file_path: lock_file_path.as_os_str().into(), - shm_id: shm_path.as_os_str().into(), + shm_id: IpcStr::from_os_c_str(keeper.path.as_c_str()).to_boxed(), }; let receiver = Receiver::new(lock_file_path, keeper, mapping)?; @@ -151,7 +150,13 @@ impl ChannelConf { let lock_file = File::open(self.lock_file_path.to_cow_os_str())?; lock_file.try_lock_shared()?; - let shm_path = os_c_string(&self.shm_id.to_cow_os_str())?; + // The arena never touches the process heap, so this stays safe in + // the preload contexts that create senders (pre-`main` constructors, + // the Windows loader lock). + let arena = fspy_nostd_alloc::arena(); + let shm_path = self.shm_id.to_os_c_string_in(&arena).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory path") + })?; let mapping = fspy_shm::open(shm_path.as_c_str().as_thin()) .map_err(shm_error_to_io)? .map()