diff --git a/Cargo.lock b/Cargo.lock index 22ed69117..be7e52c36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1345,6 +1345,7 @@ name = "fspy_nostd" version = "0.0.0" dependencies = [ "atoi", + "bitflags 2.10.0", "bstr", "libc", "rustix", @@ -1458,7 +1459,7 @@ version = "0.0.0" dependencies = [ "ctor", "fspy_nostd", - "memmap2", + "omnipath", "subprocess_test", "uuid", "windows-sys 0.61.2", @@ -2464,6 +2465,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "omnipath" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80adb31078122c880307e9cdfd4e3361e6545c319f9b9dcafcb03acd3b51a575" + [[package]] name = "once_cell" version = "1.21.3" diff --git a/Cargo.toml b/Cargo.toml index 6c96c1867..3b76cb483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ fspy_ipc_str = { path = "crates/fspy_ipc_str" } nix = { version = "0.31.2", features = ["dir", "signal"] } ntapi = "0.4.1" nucleo-matcher = "0.3.1" +omnipath = "0.1.6" once_cell = "1.19" os_str_bytes = "7.1.1" ouroboros = "0.18.5" diff --git a/crates/fspy_nostd/Cargo.toml b/crates/fspy_nostd/Cargo.toml index 02cce3f40..0691c189b 100644 --- a/crates/fspy_nostd/Cargo.toml +++ b/crates/fspy_nostd/Cargo.toml @@ -32,7 +32,14 @@ rustix = { workspace = true, features = ["runtime"] } syscalls = { workspace = true } [target.'cfg(windows)'.dependencies] -windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_LibraryLoader"] } +bitflags = { workspace = true } +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_LibraryLoader", + "Win32_System_Memory", +] } # Cross-validates the page-size probe against rustix's auxv-based answer. [target.'cfg(target_os = "linux")'.dev-dependencies] diff --git a/crates/fspy_nostd/README.md b/crates/fspy_nostd/README.md index 3404b5c86..9e918ca5e 100644 --- a/crates/fspy_nostd/README.md +++ b/crates/fspy_nostd/README.md @@ -54,8 +54,8 @@ Code that needs allocation uses an explicit allocator. [`fspy_nostd_alloc`](../f ## Modules -- `mm`: anonymous memory mapping and protection operations. +- `mm`: memory mapping and protection operations. - `env`: allocation-free process argument and environment iteration. -- `fs`: filesystem operations with caller-owned buffers. +- `fs`: filesystem operations with caller-owned paths and buffers. - `param`: page-size access. - `get_module_handle`: allocation-free lookup of an already-loaded Windows module. diff --git a/crates/fspy_nostd/src/c_str.rs b/crates/fspy_nostd/src/c_str.rs index 265250199..a8893f896 100644 --- a/crates/fspy_nostd/src/c_str.rs +++ b/crates/fspy_nostd/src/c_str.rs @@ -152,6 +152,22 @@ impl<'a, U: CStrUnit> CStr<'a, Thin, U> { } impl<'a, U: CStrUnit> CStr<'a, Fat, U> { + /// Creates a length-retaining C string from code units that end with the + /// single NUL terminator. + /// + /// Returns [`None`] when `units` is empty, does not end with a NUL code + /// unit, or contains an interior NUL code unit. + #[must_use] + pub fn from_units_with_nul(units: &'a [U]) -> Option { + let (last, rest) = units.split_last()?; + if *last != U::NUL || rest.contains(&U::NUL) { + return None; + } + // SAFETY: `units` ends with exactly one NUL code unit and contains no + // other NUL code units. + Some(unsafe { Self::from_units_with_nul_unchecked(units) }) + } + /// Creates a length-retaining C string from code units without validation. /// /// # Safety @@ -215,6 +231,18 @@ mod tests { assert_eq!(counted.as_units_with_nul(), fat.as_units_with_nul()); } + #[test] + fn checked_construction_accepts_only_a_single_trailing_nul() { + let checked = CStr::::from_units_with_nul(b"abc\0").unwrap(); + + assert_eq!(checked.as_units(), b"abc"); + assert_eq!(checked.len_with_nul(), 4); + assert!(CStr::::from_units_with_nul(b"").is_none()); + assert!(CStr::::from_units_with_nul(b"abc").is_none()); + assert!(CStr::::from_units_with_nul(b"a\0c\0").is_none()); + assert!(WideCStr::::from_units_with_nul(&[0u16]).is_some()); + } + #[test] fn thin_view_accepts_a_checked_non_null_pointer() { let ptr = NonNull::new(c"abc".as_ptr().cast_mut().cast()).unwrap(); diff --git a/crates/fspy_nostd/src/fs/mod.rs b/crates/fspy_nostd/src/fs/mod.rs index 3720e13bd..29197830c 100644 --- a/crates/fspy_nostd/src/fs/mod.rs +++ b/crates/fspy_nostd/src/fs/mod.rs @@ -1,67 +1,18 @@ //! Filesystem calls with caller-owned storage. -use core::mem::MaybeUninit; - -pub use rustix::fs::{AtFlags, Mode, OFlags, fstat, ftruncate}; - -use crate::{BorrowedFd, CStr, Fat, OwnedFd, Result}; - #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] mod mac; +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; -#[cfg(target_os = "linux")] -use linux as imp; -#[cfg(target_os = "linux")] -pub use linux::readlinkat; -#[cfg(target_os = "macos")] -use mac as imp; -#[cfg(target_os = "macos")] -pub use mac::fcntl_getpath; - -/// The platform's maximum pathname size, including the terminating NUL. -pub const PATH_MAX: usize = imp::PATH_MAX; - -/// Opens `path` relative to `dirfd` and returns its owned descriptor. -/// -/// # Errors -/// -/// Returns the error reported by `openat`. -pub fn openat( - dirfd: BorrowedFd<'_>, - path: CStr<'_, R>, - flags: OFlags, - mode: Mode, -) -> Result { - imp::openat(dirfd, path, flags, mode) -} - -/// Removes `path` relative to `dirfd`. -/// -/// # Errors -/// -/// Returns the error reported by `unlinkat`. -pub fn unlinkat(dirfd: BorrowedFd<'_>, path: CStr<'_, R>, flags: AtFlags) -> Result<()> { - imp::unlinkat(dirfd, path, flags) -} - -/// Writes the absolute pathname of the current working directory into `buf`. -/// -/// The returned C string borrows `buf`, starts at the same address as `buf`, -/// and includes a terminating NUL. Bytes after that terminator have an -/// unspecified initialization state. -/// -/// This function performs one resolution attempt and does not allocate, grow, -/// or retry. No buffer size guarantees success, including one larger than -/// [`PATH_MAX`]. -/// -/// # Errors -/// -/// Returns the error reported while resolving the current working directory. -pub fn getcwd(buf: &mut [MaybeUninit]) -> Result> { - imp::getcwd(buf) -} +#[cfg(unix)] +pub use unix::*; +#[cfg(windows)] +pub use windows::*; -#[cfg(test)] +#[cfg(all(test, unix))] mod tests; diff --git a/crates/fspy_nostd/src/fs/unix.rs b/crates/fspy_nostd/src/fs/unix.rs new file mode 100644 index 000000000..05279a393 --- /dev/null +++ b/crates/fspy_nostd/src/fs/unix.rs @@ -0,0 +1,56 @@ +use core::mem::MaybeUninit; + +pub use rustix::fs::{AtFlags, Mode, OFlags, fstat, ftruncate}; + +#[cfg(target_os = "linux")] +use super::linux as imp; +#[cfg(target_os = "linux")] +pub use super::linux::readlinkat; +#[cfg(target_os = "macos")] +use super::mac as imp; +#[cfg(target_os = "macos")] +pub use super::mac::fcntl_getpath; +use crate::{BorrowedFd, CStr, Fat, OwnedFd, Result}; + +/// The platform's maximum pathname size, including the terminating NUL. +pub const PATH_MAX: usize = imp::PATH_MAX; + +/// Opens `path` relative to `dirfd` and returns its owned descriptor. +/// +/// # Errors +/// +/// Returns the error reported by `openat`. +pub fn openat( + dirfd: BorrowedFd<'_>, + path: CStr<'_, R>, + flags: OFlags, + mode: Mode, +) -> Result { + imp::openat(dirfd, path, flags, mode) +} + +/// Removes `path` relative to `dirfd`. +/// +/// # Errors +/// +/// Returns the error reported by `unlinkat`. +pub fn unlinkat(dirfd: BorrowedFd<'_>, path: CStr<'_, R>, flags: AtFlags) -> Result<()> { + imp::unlinkat(dirfd, path, flags) +} + +/// Writes the absolute pathname of the current working directory into `buf`. +/// +/// The returned C string borrows `buf`, starts at the same address as `buf`, +/// and includes a terminating NUL. Bytes after that terminator have an +/// unspecified initialization state. +/// +/// This function performs one resolution attempt and does not allocate, grow, +/// or retry. No buffer size guarantees success, including one larger than +/// [`PATH_MAX`]. +/// +/// # Errors +/// +/// Returns the error reported while resolving the current working directory. +pub fn getcwd(buf: &mut [MaybeUninit]) -> Result> { + imp::getcwd(buf) +} diff --git a/crates/fspy_nostd/src/fs/windows.rs b/crates/fspy_nostd/src/fs/windows.rs new file mode 100644 index 000000000..6b29b1d2f --- /dev/null +++ b/crates/fspy_nostd/src/fs/windows.rs @@ -0,0 +1,115 @@ +use core::ptr; + +use bitflags::bitflags; +use windows_sys::Win32::{ + Foundation::{GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE}, + Storage::FileSystem::{ + CREATE_NEW, CreateFileW, DELETE, FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileSizeEx, OPEN_EXISTING, + TRUNCATE_EXISTING, + }, +}; + +use crate::{BorrowedHandle, OwnedHandle, Result, SecurityAttributes, WideCStr}; + +bitflags! { + /// Access rights requested for a file handle. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct FileAccess: u32 { + /// Generic read access. + const GENERIC_READ = GENERIC_READ; + /// Generic write access. + const GENERIC_WRITE = GENERIC_WRITE; + /// Permission to delete the file. + const DELETE = DELETE; + } + + /// Operations that other handles may perform while a file is open. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct FileShare: u32 { + /// Permit subsequent opens for reading. + const READ = FILE_SHARE_READ; + /// Permit subsequent opens for writing. + const WRITE = FILE_SHARE_WRITE; + /// Permit subsequent opens for deletion. + const DELETE = FILE_SHARE_DELETE; + } + + /// File attributes and creation options accepted by `CreateFileW`. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct FileOptions: u32 { + /// Hint that the file should be kept in memory when possible. + const TEMPORARY = FILE_ATTRIBUTE_TEMPORARY; + /// Open a reparse point rather than its target. + const OPEN_REPARSE_POINT = FILE_FLAG_OPEN_REPARSE_POINT; + } +} + +/// How `CreateFileW` handles an existing or missing file. +/// +/// `CREATE_ALWAYS` and `OPEN_ALWAYS` are omitted: their success reports +/// whether the file already existed only through `GetLastError`, which +/// [`create_file`] does not surface. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum CreationDisposition { + /// Create a new file and fail if it already exists. + CreateNew = CREATE_NEW, + /// Open an existing file and fail if it does not exist. + OpenExisting = OPEN_EXISTING, + /// Open and truncate an existing file. + TruncateExisting = TRUNCATE_EXISTING, +} + +/// Calls `CreateFileW` and returns the new owned handle. +/// +/// # Errors +/// +/// Returns the error reported by `CreateFileW`. +#[expect(clippy::needless_pass_by_value, reason = "CStr is a borrowed value type")] +pub fn create_file( + path: WideCStr<'_, R>, + access: FileAccess, + share: FileShare, + security_attributes: Option<&SecurityAttributes>, + disposition: CreationDisposition, + options: FileOptions, + template_file: Option>, +) -> Result { + let security_attributes = security_attributes.map_or(ptr::null(), SecurityAttributes::as_raw); + let template_file = template_file.map_or(ptr::null_mut(), |file| file.as_raw_handle()); + + // SAFETY: `path` and the optional security-attributes pointer remain + // readable for the call, and the optional template handle remains open. + // Windows validates the template's object type and all scalar options. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + access.bits(), + share.bits(), + security_attributes, + disposition as u32, + options.bits(), + template_file, + ) + }; + if handle == INVALID_HANDLE_VALUE { + Err(crate::windows::last_error()) + } else { + // SAFETY: `CreateFileW` returned a valid, newly owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) + } +} + +/// Calls `GetFileSizeEx`. +/// +/// # Errors +/// +/// Returns the error reported by `GetFileSizeEx`. +pub fn get_file_size(file: BorrowedHandle<'_>) -> Result { + let mut size = 0; + // SAFETY: `file` keeps the opaque handle open and `size` is writable for + // the call. Windows rejects handles that do not support a size query. + crate::windows::bool_result(unsafe { GetFileSizeEx(file.as_raw_handle(), &raw mut size) })?; + Ok(size) +} diff --git a/crates/fspy_nostd/src/lib.rs b/crates/fspy_nostd/src/lib.rs index 779889d51..746d2a982 100644 --- a/crates/fspy_nostd/src/lib.rs +++ b/crates/fspy_nostd/src/lib.rs @@ -13,16 +13,19 @@ mod windows; #[cfg(unix)] pub mod env; -#[cfg(unix)] +#[cfg(any(unix, windows))] pub mod fs; -#[cfg(unix)] +#[cfg(any(unix, windows))] pub mod mm; #[cfg(unix)] pub mod param; pub use c_str::{CStr, CStrUnit, Fat, Thin, Units, WideCStr}; #[cfg(windows)] -pub use windows::get_module_handle; +pub use windows::{ + BorrowedHandle, OwnedHandle, RawHandle, SecurityAttributes, bool_result, get_module_handle, + last_error, +}; #[cfg(windows)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/fspy_nostd/src/mm.rs b/crates/fspy_nostd/src/mm.rs index 7de3763f5..ce98132ea 100644 --- a/crates/fspy_nostd/src/mm.rs +++ b/crates/fspy_nostd/src/mm.rs @@ -1,11 +1,9 @@ -//! Anonymous memory mappings. -//! -//! Re-exposed from rustix as-is: these are single syscalls against the -//! kernel's own address-space bookkeeping — no libc state, no locks, no -//! allocation — so they already meet this crate's rules everywhere it -//! promises to work. What this module adds is the curation (being listed -//! here is what marks them safe for signal handlers, fork children, and -//! pre-libc startup) and the crate-level backend check, which guarantees -//! they cannot silently turn into libc calls on Linux. +//! Memory mappings with no process-runtime dependency. +#[cfg(unix)] pub use rustix::mm::{MapFlags, MprotectFlags, ProtFlags, mmap, mmap_anonymous, mprotect, munmap}; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use windows::*; diff --git a/crates/fspy_nostd/src/mm/windows.rs b/crates/fspy_nostd/src/mm/windows.rs new file mode 100644 index 000000000..60df93f42 --- /dev/null +++ b/crates/fspy_nostd/src/mm/windows.rs @@ -0,0 +1,131 @@ +use core::{ffi::c_void, ptr}; + +use bitflags::bitflags; +use windows_sys::Win32::System::Memory::{ + CreateFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + PAGE_READWRITE, UnmapViewOfFile, +}; + +use crate::{BorrowedHandle, OwnedHandle, Result, SecurityAttributes}; + +/// Page protection for a file mapping. +/// +/// Protection values are mutually exclusive, so they are modeled as an enum +/// rather than as flags. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum PageProtection { + /// Permit pages to be read and written. + ReadWrite = PAGE_READWRITE, +} + +bitflags! { + /// Access requested for a mapped view. + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct MappingAccess: u32 { + /// Permit reads from the view. + const READ = FILE_MAP_READ; + /// Permit writes to the view. + const WRITE = FILE_MAP_WRITE; + } +} + +/// An owned view of a file-mapping object. +pub struct MappingView { + ptr: core::ptr::NonNull, +} + +// SAFETY: a view owns no thread-affine state. Synchronization of accesses to +// its shared bytes is the caller's responsibility. +unsafe impl Send for MappingView {} +// SAFETY: sharing the view does not itself access the mapped bytes. +unsafe impl Sync for MappingView {} + +impl MappingView { + /// Returns a raw pointer to the first mapped byte. + #[must_use] + pub const fn as_ptr(&self) -> *mut u8 { + self.ptr.as_ptr() + } +} + +impl Drop for MappingView { + fn drop(&mut self) { + // SAFETY: this is the complete view owned by `self` and is unmapped + // exactly once. + let _ = unsafe { + UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.ptr.as_ptr().cast::(), + }) + }; + } +} + +/// Calls `CreateFileMappingW` and returns the new unnamed mapping object's +/// handle. +/// +/// Named mappings are unsupported: for a name that already exists, +/// `CreateFileMappingW` reports the pre-existing object only through +/// `GetLastError`, which this wrapper does not surface. +/// +/// # Errors +/// +/// Returns the error reported by `CreateFileMappingW`. +pub fn create_file_mapping( + file: BorrowedHandle<'_>, + mapping_attributes: Option<&SecurityAttributes>, + protection: PageProtection, + maximum_size_high: u32, + maximum_size_low: u32, +) -> Result { + let mapping_attributes = mapping_attributes.map_or(ptr::null(), SecurityAttributes::as_raw); + + // SAFETY: `file` keeps the opaque handle open. The optional security + // attributes are either null or readable for the call. Windows validates + // the handle's object type, protection, and sizes. + let mapping = unsafe { + CreateFileMappingW( + file.as_raw_handle(), + mapping_attributes, + protection as u32, + maximum_size_high, + maximum_size_low, + ptr::null(), + ) + }; + if mapping.is_null() { + return Err(crate::windows::last_error()); + } + // SAFETY: `CreateFileMappingW` returned a valid, newly owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(mapping) }) +} + +/// Calls `MapViewOfFile` and returns the new owned view. +/// +/// # Errors +/// +/// Returns the error reported by `MapViewOfFile`. +pub fn map_view_of_file( + mapping: BorrowedHandle<'_>, + access: MappingAccess, + file_offset_high: u32, + file_offset_low: u32, + bytes_to_map: usize, +) -> Result { + // SAFETY: `mapping` keeps the opaque handle open for the call. Windows + // verifies that it names a file-mapping object and validates the access, + // offset, and size; any mismatch is reported as a null result. + let view = unsafe { + MapViewOfFile( + mapping.as_raw_handle(), + access.bits(), + file_offset_high, + file_offset_low, + bytes_to_map, + ) + }; + let Some(ptr) = core::ptr::NonNull::new(view.Value.cast::()) else { + return Err(crate::windows::last_error()); + }; + Ok(MappingView { ptr }) +} diff --git a/crates/fspy_nostd/src/windows.rs b/crates/fspy_nostd/src/windows.rs index b2b4d369f..1a70e5d4a 100644 --- a/crates/fspy_nostd/src/windows.rs +++ b/crates/fspy_nostd/src/windows.rs @@ -1,9 +1,130 @@ -use core::{ffi::c_void, ptr::NonNull}; +use core::{ffi::c_void, marker::PhantomData, ptr::NonNull}; -use windows_sys::Win32::{Foundation::GetLastError, System::LibraryLoader::GetModuleHandleW}; +use windows_sys::Win32::{ + Foundation::GetLastError, Security::SECURITY_ATTRIBUTES, + System::LibraryLoader::GetModuleHandleW, +}; use crate::{Result, WideCStr}; +/// A raw Windows kernel handle. +pub type RawHandle = *mut c_void; + +/// A borrowed Windows kernel handle. +/// +/// Its lifetime is tied to the value that keeps the handle open. The sentinel +/// values `NULL` and `-1` are also permitted because some Win32 calls define +/// them as meaningful arguments. +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct BorrowedHandle<'handle> { + handle: RawHandle, + lifetime: PhantomData<&'handle OwnedHandle>, +} + +/// An owned Windows kernel handle. +/// +/// The handle is closed on drop, so sentinel values such as `NULL` and `-1` +/// are never permitted here. +#[repr(transparent)] +pub struct OwnedHandle { + handle: RawHandle, +} + +/// Opaque security attributes accepted by Win32 creation functions. +/// +/// This type has no public constructor. Non-default security attributes will +/// remain unavailable to safe callers until their embedded security +/// descriptor can be represented safely. +#[repr(transparent)] +pub struct SecurityAttributes(SECURITY_ATTRIBUTES); + +impl SecurityAttributes { + pub(crate) const fn as_raw(&self) -> *const SECURITY_ATTRIBUTES { + &raw const self.0 + } +} + +impl BorrowedHandle<'_> { + /// Borrows a raw handle. + /// + /// # Safety + /// + /// `handle` must be a valid open handle that remains open for the lifetime + /// of the returned value, or a sentinel value (`NULL` or `-1`) that every + /// call receiving the borrow defines as meaningful. + #[must_use] + pub const unsafe fn borrow_raw(handle: RawHandle) -> Self { + Self { handle, lifetime: PhantomData } + } + + /// Returns the raw handle without transferring ownership. + #[must_use] + pub const fn as_raw_handle(&self) -> RawHandle { + self.handle + } +} + +impl OwnedHandle { + /// Creates an owned handle after the caller has validated the raw value. + /// + /// # Safety + /// + /// `handle` must be a valid, uniquely owned handle that may be closed with + /// `CloseHandle`. + pub(crate) const unsafe fn from_raw_handle(handle: RawHandle) -> Self { + Self { handle } + } + + /// Borrows this handle. + #[must_use] + pub const fn as_handle(&self) -> BorrowedHandle<'_> { + // SAFETY: `self` keeps the same valid handle open for the returned + // borrow's lifetime. + unsafe { BorrowedHandle::borrow_raw(self.handle) } + } +} + +// SAFETY: Windows kernel handles are process-scoped and may be moved between +// threads. Moving this value does not access the referenced kernel object. +unsafe impl Send for OwnedHandle {} +// SAFETY: as above; the borrow does not add thread affinity. +unsafe impl Send for BorrowedHandle<'_> {} +// SAFETY: sharing a handle value does not access the kernel object. Each +// operation is responsible for the synchronization required by that object. +unsafe impl Sync for OwnedHandle {} +// SAFETY: as above; sharing the borrowed value does not access the object. +unsafe impl Sync for BorrowedHandle<'_> {} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this type owns a valid handle and closes it exactly once. + let _ = unsafe { windows_sys::Win32::Foundation::CloseHandle(self.handle) }; + } +} + +/// Returns the calling thread's last Win32 error. +/// +/// Call this immediately after the failing Win32 call: anything in between, +/// including drops, can overwrite the thread-local error code. +#[must_use] +pub fn last_error() -> crate::Error { + // SAFETY: `GetLastError` reads thread-local error state. + crate::Error::from_raw_os_error(unsafe { GetLastError() }) +} + +/// Converts a Win32 `BOOL` result into a [`Result`]. +/// +/// As with [`last_error`], call this immediately after the Win32 call whose +/// result it receives. +/// +/// # Errors +/// +/// Returns the calling thread's last Win32 error when `result` is zero. +pub fn bool_result(result: i32) -> Result<()> { + if result == 0 { Err(last_error()) } else { Ok(()) } +} + /// Returns a handle to the loaded module named by `name`. /// /// This does not load the module or increment its loader reference count. The diff --git a/crates/fspy_shm/Cargo.toml b/crates/fspy_shm/Cargo.toml index 70115da56..e53ea56b0 100644 --- a/crates/fspy_shm/Cargo.toml +++ b/crates/fspy_shm/Cargo.toml @@ -10,13 +10,11 @@ rust-version.workspace = true [dependencies] uuid = { workspace = true, features = ["v4"] } -[target.'cfg(unix)'.dependencies] +[target.'cfg(any(unix, windows))'.dependencies] fspy_nostd = { workspace = true } -[target.'cfg(windows)'.dependencies] -memmap2 = { workspace = true } - [target.'cfg(target_os = "windows")'.dependencies] +omnipath = { workspace = true } windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Storage_FileSystem", diff --git a/crates/fspy_shm/src/windows.rs b/crates/fspy_shm/src/windows.rs index c7c47446b..8a7582a8d 100644 --- a/crates/fspy_shm/src/windows.rs +++ b/crates/fspy_shm/src/windows.rs @@ -1,16 +1,24 @@ //! Windows shared memory backed by a sparse temporary file and identified by //! its path. +use core::{ffi::c_void, mem::size_of, ptr}; use std::{ env::temp_dir, ffi::OsStr, - fs::{self, File, OpenOptions}, io, - os::windows::{fs::OpenOptionsExt as _, io::AsRawHandle as _}, - path::PathBuf, + num::NonZeroUsize, + os::windows::ffi::OsStrExt as _, + path::{Path, PathBuf}, }; +#[cfg(test)] +use std::{fs::File, os::windows::io::AsRawHandle as _}; -use memmap2::{MmapOptions, MmapRaw}; +use fspy_nostd::{ + BorrowedHandle, bool_result, + fs::{CreationDisposition, FileAccess, FileOptions, FileShare}, + mm::{MappingAccess, PageProtection}, +}; +use omnipath::windows::WinPathExt as _; use uuid::Uuid; #[cfg(test)] use windows_sys::Win32::Storage::FileSystem::{ @@ -18,21 +26,23 @@ use windows_sys::Win32::Storage::FileSystem::{ }; use windows_sys::Win32::{ Storage::FileSystem::{ - FILE_ATTRIBUTE_TEMPORARY, FILE_FLAG_DELETE_ON_CLOSE, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, + FILE_DISPOSITION_FLAG_DELETE, FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, + FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_END_OF_FILE_INFO, + FileDispositionInfoEx, FileEndOfFileInfo, SetFileInformationByHandle, }, System::{IO::DeviceIoControl, Ioctl::FSCTL_SET_SPARSE}, }; use crate::BACKING_PREFIX; -const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; -const TEMPORARY: u32 = FILE_ATTRIBUTE_TEMPORARY; -const DELETE_ON_CLOSE: u32 = FILE_FLAG_DELETE_ON_CLOSE; -const DELETE_ACCESS: u32 = windows_sys::Win32::Storage::FileSystem::DELETE; +const SHARE_ALL: FileShare = FileShare::READ.union(FileShare::WRITE).union(FileShare::DELETE); /// Keeps the shared memory's identifier alive and removes it on drop. /// +/// Removal relies on POSIX delete semantics, which requires NTFS on Windows +/// 10 1607 or newer: the name is unlinked immediately even while views of the +/// backing file remain mapped. +/// /// Removal is cleanup, not a stop signal: later opens fail, but existing /// [`ShmHandle`]s and [`Mapping`]s keep reading and writing. To stop them, /// store a flag in the shared bytes, as the fspy channel's close gate does. @@ -45,8 +55,8 @@ pub struct ShmKeeper { /// [`map`](Self::map) can be called more than once; every call returns another /// view of the same bytes. Drop the handle once the mappings exist. pub struct ShmHandle { - file: File, - size: usize, + file: fspy_nostd::OwnedHandle, + size: NonZeroUsize, } /// The mapped shared bytes. @@ -54,7 +64,8 @@ pub struct ShmHandle { /// A `Mapping` keeps the bytes alive until it is dropped and cannot affect the /// shared memory's identifier. pub struct Mapping { - raw: MmapRaw, + view: fspy_nostd::mm::MappingView, + len: NonZeroUsize, } /// Creates `size` bytes of zero-initialized shared memory. @@ -70,37 +81,35 @@ pub struct Mapping { /// Returns an error if the shared memory cannot be created or sized. Creation /// fails on volumes without sparse-file support. pub fn create(size: usize) -> io::Result<(ShmKeeper, ShmHandle)> { - if size == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "shared-memory size must be nonzero", - )); - } - let size_u64 = u64::try_from(size).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64") + let size = NonZeroUsize::new(size).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size must be nonzero") + })?; + let size_i64 = i64::try_from(size.get()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds i64") })?; // The per-user `%TEMP%` ACL provides same-user gating. The identifier is // absolute so it keeps working after a working-directory change. let path = std::path::absolute(temp_dir())? .join(format!("{BACKING_PREFIX}{}.shm", Uuid::new_v4().simple())); - let file = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .share_mode(SHARE_ALL) - // Ask Windows to keep the data in memory when it can. - .attributes(TEMPORARY) - .open(&path)?; + let file = open_file( + path.as_os_str(), + FileAccess::GENERIC_READ | FileAccess::GENERIC_WRITE, + CreationDisposition::CreateNew, + // Ask Windows to keep the data in memory when it can. Opening the + // reparse point itself makes a link planted at the fresh path fail + // instead of redirecting the file, as std does for `create_new`. + FileOptions::TEMPORARY | FileOptions::OPEN_REPARSE_POINT, + )?; // The keeper exists from here on, so every error path below cleans up. let keeper = ShmKeeper { path }; // NTFS allocates clusters for the whole logical size unless the file is // marked sparse first, which would turn the capacity into real disk usage. // Volumes without sparse-file support fail here. - set_sparse(&file)?; + set_sparse(file.as_handle()).map_err(error_to_io)?; // Every byte reads as zero because the file is all holes. - file.set_len(size_u64)?; + set_end_of_file(file.as_handle(), size_i64).map_err(error_to_io)?; Ok((keeper, ShmHandle { file, size })) } @@ -115,33 +124,151 @@ pub fn create(size: usize) -> io::Result<(ShmKeeper, ShmHandle)> { /// Returns an error if the shared memory is unavailable, which is the common /// case once its keeper has been dropped. pub fn open(id: &OsStr) -> io::Result { - // Rust handles are non-inheritable, and its default share mode permits - // concurrent read, write and delete access. - let file = OpenOptions::new().read(true).write(true).open(id)?; + let file = open_file( + id, + FileAccess::GENERIC_READ | FileAccess::GENERIC_WRITE, + CreationDisposition::OpenExisting, + FileOptions::empty(), + )?; // If another process shrinks the file before `map`, mapping fails. If it // resizes afterwards, nothing here touches the mapped pages. A concurrent // resize cannot make a mapping access invalid memory. - let size = usize::try_from(file.metadata()?.len()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size"))?; - if size == 0 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero")); - } + let size = + usize::try_from(fspy_nostd::fs::get_file_size(file.as_handle()).map_err(error_to_io)?) + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid shared-memory size") + })?; + let size = NonZeroUsize::new(size) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "shared-memory size is zero"))?; Ok(ShmHandle { file, size }) } +fn open_file( + path: &OsStr, + access: FileAccess, + disposition: CreationDisposition, + options: FileOptions, +) -> io::Result { + let path = copy_path(path)?; + open_file_wide(as_nostd_path(&path), access, disposition, options).map_err(error_to_io) +} + +fn open_file_wide( + path: fspy_nostd::WideCStr<'_, fspy_nostd::Fat>, + access: FileAccess, + disposition: CreationDisposition, + options: FileOptions, +) -> fspy_nostd::Result { + fspy_nostd::fs::create_file(path, access, SHARE_ALL, None, disposition, options, None) +} + +/// The length at which std's own Windows path conversion switches to a +/// verbatim path. +const VERBATIM_THRESHOLD: usize = 248; + +fn copy_path(path: &OsStr) -> io::Result> { + let mut units: Vec<_> = path.encode_wide().collect(); + if units.contains(&0) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL")); + } + // std converts long paths to verbatim form before every `CreateFileW`; + // without that, paths at or beyond the legacy `MAX_PATH` limit fail + // regardless of the system's long-path opt-in, which the arbitrary + // processes opening shared memory could not rely on anyway. + if units.len() >= VERBATIM_THRESHOLD { + units = Path::new(path).to_verbatim()?.as_os_str().encode_wide().collect(); + } + units.push(0); + Ok(units) +} + +fn as_nostd_path(path: &[u16]) -> fspy_nostd::WideCStr<'_, fspy_nostd::Fat> { + fspy_nostd::WideCStr::from_units_with_nul(path) + .expect("copy_path rejects interior NUL and appends one terminator") +} + +fn error_to_io(error: fspy_nostd::Error) -> io::Error { + io::Error::from_raw_os_error(error.raw_os_error().cast_signed()) +} + +fn set_sparse(file: BorrowedHandle<'_>) -> fspy_nostd::Result<()> { + let mut bytes_returned = 0; + // SAFETY: `file` keeps the handle open, and every caller supplies a file + // opened without `FILE_FLAG_OVERLAPPED`. `FSCTL_SET_SPARSE` accepts null + // input and output buffers, and `bytes_returned` is writable. Windows + // validates that the handle supports this control code. + bool_result(unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_SPARSE, + ptr::null(), + 0, + ptr::null_mut(), + 0, + &raw mut bytes_returned, + ptr::null_mut(), + ) + }) +} + +fn set_end_of_file(file: BorrowedHandle<'_>, len: i64) -> fspy_nostd::Result<()> { + const INFO_SIZE: u32 = 8; + const _: [(); 8] = [(); size_of::()]; + + let info = FILE_END_OF_FILE_INFO { EndOfFile: len }; + // SAFETY: `file` keeps the opaque handle open. `info` is initialized and + // has the type and exact size required by `FileEndOfFileInfo`; Windows + // validates the handle type and access rights. + bool_result(unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileEndOfFileInfo, + (&raw const info).cast::(), + INFO_SIZE, + ) + }) +} + +fn remove_file(path: &OsStr) -> io::Result<()> { + let path = copy_path(path)?; + // Opening the reparse point itself removes a link rather than its target. + let file = open_file_wide( + as_nostd_path(&path), + FileAccess::DELETE, + CreationDisposition::OpenExisting, + FileOptions::OPEN_REPARSE_POINT, + ) + .map_err(error_to_io)?; + // POSIX delete removes the name as soon as `file` closes below, while + // existing handles and mapped views keep working until they are dropped. + set_posix_delete(file.as_handle()).map_err(error_to_io) +} + +fn set_posix_delete(file: BorrowedHandle<'_>) -> fspy_nostd::Result<()> { + const INFO_SIZE: u32 = 4; + const _: [(); 4] = [(); size_of::()]; + + let info = FILE_DISPOSITION_INFO_EX { + Flags: FILE_DISPOSITION_FLAG_DELETE + | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS + | FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, + }; + // SAFETY: `file` keeps the opaque handle open. `info` is initialized and + // has the type and exact size required by `FileDispositionInfoEx`; Windows + // validates the handle type, access rights, and supported flags. + bool_result(unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileDispositionInfoEx, + (&raw const info).cast::(), + INFO_SIZE, + ) + }) +} + impl Drop for ShmKeeper { fn drop(&mut self) { - // Windows versions without POSIX delete refuse to remove the name of a - // mapped file. Arm the deferred delete instead: a handle opened with - // `FILE_FLAG_DELETE_ON_CLOSE` deletes the file once every handle to it - // is closed. - if fs::remove_file(&self.path).is_err() { - let _ = OpenOptions::new() - .access_mode(DELETE_ACCESS) - .share_mode(SHARE_ALL) - .custom_flags(DELETE_ON_CLOSE) - .open(&self.path); - } + let _ = remove_file(self.path.as_os_str()); } } @@ -161,7 +288,30 @@ impl ShmHandle { /// /// Returns an error if the mapping cannot be established. pub fn map(&self) -> io::Result { - Ok(Mapping { raw: MmapOptions::new().len(self.size).map_raw(&self.file)? }) + let _slice_len = isize::try_from(self.size.get()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "shared-memory size exceeds isize") + })?; + // Default security attributes create a non-inheritable mapping object. + // Both maximum-size halves are zero, so Windows uses the current file + // size. + let mapping = fspy_nostd::mm::create_file_mapping( + self.file.as_handle(), + None, + PageProtection::ReadWrite, + 0, + 0, + ) + .map_err(error_to_io)?; + let view = fspy_nostd::mm::map_view_of_file( + mapping.as_handle(), + MappingAccess::READ | MappingAccess::WRITE, + 0, + 0, + self.size.get(), + ) + .map_err(error_to_io)?; + // The view remains valid after its mapping-object handle closes. + Ok(Mapping { view, len: self.size }) } } @@ -169,14 +319,14 @@ impl ShmHandle { impl Mapping { /// Returns the mapped length in bytes. #[must_use] - pub fn len(&self) -> usize { - self.raw.len() + pub const fn len(&self) -> usize { + self.len.get() } /// Returns a raw pointer to the first mapped byte. #[must_use] - pub fn as_ptr(&self) -> *mut u8 { - self.raw.as_mut_ptr() + pub const fn as_ptr(&self) -> *mut u8 { + self.view.as_ptr() } /// Returns the mapped bytes as a shared slice. @@ -186,42 +336,22 @@ impl Mapping { /// The caller must ensure that no process or thread mutates the mapping for /// the lifetime of the returned slice. #[must_use] - pub unsafe fn as_slice(&self) -> &[u8] { + pub const unsafe fn as_slice(&self) -> &[u8] { // SAFETY: The mapping is valid for its full length, and the caller // guarantees that it is not mutated while the slice is borrowed. - unsafe { std::slice::from_raw_parts(self.as_ptr().cast_const(), self.len()) } + unsafe { core::slice::from_raw_parts(self.as_ptr().cast_const(), self.len()) } } } -/// Marks `file` sparse so that setting its length reserves no clusters. -fn set_sparse(file: &File) -> io::Result<()> { - let mut bytes_returned = 0; - // SAFETY: `file` supplies a valid synchronous file handle. FSCTL_SET_SPARSE - // requires no input or output buffers, and `bytes_returned` is writable for - // the duration of the call. - let result = unsafe { - DeviceIoControl( - file.as_raw_handle().cast(), - FSCTL_SET_SPARSE, - std::ptr::null(), - 0, - std::ptr::null_mut(), - 0, - &raw mut bytes_returned, - std::ptr::null_mut(), - ) - }; - if result == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } -} - /// Returns the backing file's logical size and allocated byte count. #[cfg(test)] pub fn file_sizes(file: &File) -> io::Result<(u64, u64)> { let mut info = FILE_STANDARD_INFO::default(); let info_size = u32::try_from(std::mem::size_of::()) .map_err(|_| io::Error::other("file size information is too large"))?; - // SAFETY: `file` supplies a valid handle and `info` is a writable - // FILE_STANDARD_INFO buffer of exactly `info_size` bytes. + // SAFETY: `file` keeps its handle open and `info` is a writable + // `FILE_STANDARD_INFO` buffer of exactly `info_size` bytes. Windows + // validates that the handle supports this information class. let result = unsafe { GetFileInformationByHandleEx( file.as_raw_handle().cast(),