diff --git a/CHANGELOG.md b/CHANGELOG.md index 380ce3eed..76d8ba7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer crashes with `SIGBUS` when fspy's path-access stream exceeds the size of `/dev/shm` (e.g. Docker's 64 MiB default). The shared-memory IPC channel now backs its mapping with a regular file in `temp_dir()` on Linux, which is bounded by the overlay/host filesystem (typically gigabytes) rather than the small `/dev/shm` tmpfs cap. macOS and Windows are unaffected ([#1453](https://github.com/voidzero-dev/vite-plus/issues/1453)) - **Fixed** `vp run` no longer aborts with `failed to prepare the command for injection: Invalid argument` when the user environment already has `LD_PRELOAD` (Linux) or `DYLD_INSERT_LIBRARIES` (macOS) set. The tracer shim is now appended to any existing value and placed last, so user preloads keep their symbol-interposition precedence ([#340](https://github.com/voidzero-dev/vite-task/issues/340)) - **Changed** Arguments passed after a task name (e.g. `vp run test some-filter`) are now forwarded only to that task. Tasks pulled in via `dependsOn` no longer receive them ([#324](https://github.com/voidzero-dev/vite-task/issues/324)) - **Fixed** Windows file access tracking no longer panics when a task touches malformed paths that cannot be represented as workspace-relative inputs ([#330](https://github.com/voidzero-dev/vite-task/pull/330)) diff --git a/Cargo.lock b/Cargo.lock index 387e42633..b539c86b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,10 +1295,11 @@ dependencies = [ "bstr", "bytemuck", "ctor", + "fspy_shared_memory", "native_str", + "nix 0.30.1", "os_str_bytes", "rustc-hash", - "shared_memory", "subprocess_test", "thiserror 2.0.18", "tracing", @@ -1307,6 +1308,18 @@ dependencies = [ "wincode", ] +[[package]] +name = "fspy_shared_memory" +version = "0.0.0" +dependencies = [ + "assert2", + "ctor", + "nix 0.30.1", + "shared_memory", + "subprocess_test", + "uuid", +] + [[package]] name = "fspy_shared_unix" version = "0.0.0" @@ -3144,7 +3157,6 @@ checksum = "ba8593196da75d9dc4f69349682bd4c2099f8cde114257d1ef7ef1b33d1aba54" dependencies = [ "cfg-if", "libc", - "log", "nix 0.23.2", "rand 0.8.5", "win-sys", diff --git a/Cargo.toml b/Cargo.toml index 28e3e250c..b5b5cd1e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ fspy_preload_unix = { path = "crates/fspy_preload_unix", artifact = "cdylib", ta fspy_preload_windows = { path = "crates/fspy_preload_windows", artifact = "cdylib", target = "target" } fspy_seccomp_unotify = { path = "crates/fspy_seccomp_unotify" } fspy_shared = { path = "crates/fspy_shared" } +fspy_shared_memory = { path = "crates/fspy_shared_memory" } fspy_shared_unix = { path = "crates/fspy_shared_unix" } futures = "0.3.31" futures-util = "0.3.31" diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index 78ae69704..923fb19c6 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -12,7 +12,7 @@ bitflags = { workspace = true } bstr = { workspace = true } bytemuck = { workspace = true, features = ["must_cast", "derive"] } native_str = { workspace = true } -shared_memory = { workspace = true, features = ["logging"] } +fspy_shared_memory = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } @@ -26,9 +26,12 @@ winapi = { workspace = true, features = ["std"] } assert2 = { workspace = true } ctor = { workspace = true } rustc-hash = { workspace = true } -shared_memory = { workspace = true, features = ["logging"] } +fspy_shared_memory = { workspace = true } subprocess_test = { workspace = true } +[target.'cfg(target_os = "linux")'.dev-dependencies] +nix = { workspace = true, features = ["mount", "sched", "user"] } + [lints] workspace = true diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index eb0738129..ba456f63d 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -4,7 +4,7 @@ mod shm_io; use std::{env::temp_dir, fs::File, io, mem::MaybeUninit, ops::Deref, path::PathBuf, sync::Arc}; -use shared_memory::{Shmem, ShmemConf}; +use fspy_shared_memory::Shmem; pub use shm_io::FrameMut; use shm_io::{ShmReader, ShmWriter}; use tracing::debug; @@ -63,22 +63,11 @@ 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())); - #[cfg_attr( - not(windows), - expect(unused_mut, reason = "mut required on Windows, unused on Unix") - )] - let mut conf = ShmemConf::new().size(capacity); - // On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios - #[cfg(target_os = "windows")] - { - conf = conf.allow_raw(true); - } - - let shm = conf.create().map_err(io::Error::other)?; + let shm = Shmem::create(capacity)?; let conf = ChannelConf { lock_file_path: lock_file_path.as_os_str().into(), - shm_id: shm.get_os_id().into(), + shm_id: shm.os_id().into(), shm_size: capacity, }; @@ -98,17 +87,7 @@ impl ChannelConf { let lock_file = File::open(self.lock_file_path.to_cow_os_str())?; lock_file.try_lock_shared()?; - #[cfg_attr( - not(windows), - expect(unused_mut, reason = "mut required on Windows, unused on Unix") - )] - let mut conf = ShmemConf::new().size(self.shm_size).os_id(&self.shm_id); - // On Windows, allow opening raw shared memory (without backing file) for DLL injection scenarios - #[cfg(target_os = "windows")] - { - conf = conf.allow_raw(true); - } - let shm = conf.open().map_err(io::Error::other)?; + let shm = Shmem::open(&self.shm_id, self.shm_size)?; // SAFETY: `shm` is a freshly opened shared memory region with valid pointer and size. // Exclusive write access is ensured by the shared file lock held by this sender. let writer = unsafe { ShmWriter::new(shm) }; @@ -299,4 +278,120 @@ mod tests { received_values.sort_unstable(); assert_eq!(received_values, (0u16..200).collect::>()); } + + /// Regression test for . + /// + /// The current implementation backs the channel with POSIX shared memory + /// (`shm_open`), which stores its file under `/dev/shm`. On hosts where + /// `/dev/shm` is size-capped (e.g. Docker's 64 MiB default) a workload + /// whose path-access stream exceeds that cap triggers `SIGBUS` in the + /// sender when tmpfs can't allocate the next page. `cache: false` works + /// around it by skipping fspy entirely. + /// + /// This test reproduces the crash without `sudo` and without needing the + /// test environment itself to have a small `/dev/shm`: it enters an + /// unprivileged user+mount namespace in a subprocess and remounts + /// `/dev/shm` as a 1 MiB tmpfs, then writes past the cap via the real + /// `channel()` API. The test asserts the subprocess completes cleanly; + /// today it dies from `SIGBUS`. Switching the backing store to + /// `memfd_create` (which is sized against RAM + overcommit, not + /// `/dev/shm`) will let this test pass unchanged — the subprocess's + /// `/dev/shm` constraint becomes irrelevant. + #[test] + #[cfg(target_os = "linux")] + #[cfg_attr(miri, ignore = "miri can't mmap or unshare")] + fn channel_survives_constrained_dev_shm() { + use std::os::unix::process::ExitStatusExt; + + // Capacity chosen to comfortably exceed the 1 MiB tmpfs cap. The + // `ftruncate` inside `shared_memory` is lazy on tmpfs, so this + // allocation itself succeeds; the crash happens when the sender + // later writes into pages that tmpfs can no longer back. + const CAPACITY: usize = 16 * 1024 * 1024; + + let cmd = command_for_fn!((), |(): ()| { + enter_userns_with_small_dev_shm(); + + let (conf, _receiver) = super::channel(CAPACITY).expect("channel creation"); + let sender = conf.sender().expect("sender creation"); + + // Claim a single 4 MiB frame and fill it byte-by-byte. The + // first ~1 MiB of writes fit within the tmpfs quota; the next + // byte faults on an un-backed page -> SIGBUS. + let frame_size = NonZeroUsize::new(4 * 1024 * 1024).unwrap(); + let mut frame = sender.claim_frame(frame_size).expect("claim_frame"); + frame.fill(0xAB); + }); + + let status = std::process::Command::from(cmd).status().unwrap(); + + assert!( + status.success(), + "channel writes should survive a constrained /dev/shm, but the \ + subprocess exited abnormally: code={:?} signal={:?}. \ + SIGBUS ({sigbus}) indicates the issue #1453 reproduction: tmpfs \ + page allocation failed on a write to the shm-backed mapping.", + status.code(), + status.signal(), + sigbus = nix::sys::signal::Signal::SIGBUS as i32, + ); + } + + /// Procfs files must be opened without `O_CREAT` — synthetic inodes + /// reject the create bit on some hosts with `EACCES`. `std::fs::write` + /// uses `File::create` (which sets `O_CREAT`), so we can't use it here. + #[cfg(target_os = "linux")] + fn write_procfs(path: &str, content: &str) -> std::io::Result<()> { + use std::io::Write; + let mut f = std::fs::OpenOptions::new().write(true).open(path)?; + f.write_all(content.as_bytes()) + } + + /// Enter a fresh user + mount namespace in which the current uid is + /// mapped to 0, then remount `/dev/shm` as a 1 MiB tmpfs. Must be called + /// before any threads are spawned in the current process. Panics on any + /// failure — unprivileged user namespace support is a hard requirement + /// for this reproduction. + #[cfg(target_os = "linux")] + fn enter_userns_with_small_dev_shm() { + use nix::{ + mount::{MsFlags, mount}, + sched::{CloneFlags, unshare}, + unistd::{Gid, Uid}, + }; + + let uid = Uid::current().as_raw(); + let gid = Gid::current().as_raw(); + + unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS) + .expect("unshare(CLONE_NEWUSER|CLONE_NEWNS)"); + + // Inside the new user namespace the current process starts as + // "nobody" until the id maps are written. + write_procfs("/proc/self/uid_map", &std::format!("0 {uid} 1\n")) + .expect("write /proc/self/uid_map"); + // setgroups must be denied before an unprivileged gid_map write + // will be accepted (user_namespaces(7)). An absent + // /proc/self/setgroups means setgroups(2) is already permanently + // denied in an ancestor user namespace, so the gid_map + // precondition is already satisfied — not an environment skip. + match write_procfs("/proc/self/setgroups", "deny") { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!("write /proc/self/setgroups: {err}"), + } + write_procfs("/proc/self/gid_map", &std::format!("0 {gid} 1\n")) + .expect("write /proc/self/gid_map"); + + // Make the root mount private recursively so tmpfs mounts inside + // this namespace don't propagate back to the host. + mount(None::<&str>, "/", None::<&str>, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>) + .expect("mount --make-rprivate /"); + + // Remount /dev/shm as a 1 MiB tmpfs. The size= option is honored by + // tmpfs and enforced at page-fault time: accesses to pages the + // tmpfs can't back raise SIGBUS. + mount(Some("tmpfs"), "/dev/shm", Some("tmpfs"), MsFlags::empty(), Some("size=1m")) + .expect("mount tmpfs size=1m at /dev/shm"); + } } diff --git a/crates/fspy_shared/src/ipc/channel/shm_io.rs b/crates/fspy_shared/src/ipc/channel/shm_io.rs index 7890043de..9b0abfba9 100644 --- a/crates/fspy_shared/src/ipc/channel/shm_io.rs +++ b/crates/fspy_shared/src/ipc/channel/shm_io.rs @@ -9,7 +9,7 @@ use std::{ }; use bytemuck::must_cast; -use shared_memory::Shmem; +use fspy_shared_memory::Shmem; use wincode::{SchemaWrite, Serialize as _, config::DefaultConfig}; // `ShmWriter` writes headers using atomic operations to prevent partial writes due to crashes, @@ -665,21 +665,21 @@ mod tests { #[test] #[cfg(not(miri))] fn real_shm_across_processes() { - use shared_memory::ShmemConf; use subprocess_test::command_for_fn; const CHILD_COUNT: usize = 12; const FRAME_COUNT_EACH_CHILD: usize = 100; + const SHM_SIZE: usize = 1024 * 1024; - let shm = ShmemConf::new().size(1024 * 1024).create().unwrap(); - let shm_name = shm.get_os_id().to_owned(); + let shm = Shmem::create(SHM_SIZE).unwrap(); + let shm_name = shm.os_id().to_owned(); let children: Vec = (0..CHILD_COUNT) .map(|child_index| { let cmd = command_for_fn!( (shm_name.clone(), child_index), |(shm_name, child_index): (String, usize)| { - let shm = ShmemConf::new().os_id(shm_name).open().unwrap(); + let shm = Shmem::open(&shm_name, SHM_SIZE).unwrap(); // SAFETY: `shm` is a freshly opened shared memory region with a valid // pointer and size. Concurrent write access is safe because `ShmWriter` // uses atomic operations. diff --git a/crates/fspy_shared_memory/.clippy.toml b/crates/fspy_shared_memory/.clippy.toml new file mode 120000 index 000000000..c7929b369 --- /dev/null +++ b/crates/fspy_shared_memory/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/fspy_shared_memory/Cargo.toml b/crates/fspy_shared_memory/Cargo.toml new file mode 100644 index 000000000..43d25b399 --- /dev/null +++ b/crates/fspy_shared_memory/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "fspy_shared_memory" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +uuid = { workspace = true, features = ["v4"] } + +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["mman"] } + +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +shared_memory = { workspace = true } + +[dev-dependencies] +assert2 = { workspace = true } +ctor = { workspace = true } +subprocess_test = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false + +[package.metadata.cargo-shear] +ignored = ["ctor"] diff --git a/crates/fspy_shared_memory/src/external.rs b/crates/fspy_shared_memory/src/external.rs new file mode 100644 index 000000000..51950b75f --- /dev/null +++ b/crates/fspy_shared_memory/src/external.rs @@ -0,0 +1,52 @@ +use std::{io, num::NonZeroUsize}; + +use shared_memory::ShmemError; + +pub struct Inner { + shm: shared_memory::Shmem, +} + +impl Inner { + pub fn create(size: NonZeroUsize) -> io::Result { + let shm = shared_memory::ShmemConf::new().size(size.get()).create().map_err(to_io_error)?; + Ok(Self { shm }) + } + + pub fn open(os_id: &str, size: NonZeroUsize) -> io::Result { + let shm = shared_memory::ShmemConf::new() + .size(size.get()) + .os_id(os_id) + .open() + .map_err(to_io_error)?; + Ok(Self { shm }) + } + + pub fn os_id(&self) -> &str { + self.shm.get_os_id() + } + + pub fn as_ptr(&self) -> *mut u8 { + self.shm.as_ptr() + } + + pub fn len(&self) -> usize { + self.shm.len() + } +} + +/// Translate `shared_memory`'s opaque error into an `io::Error`, preserving +/// the OS error code when present so callers can dispatch on `ErrorKind` +/// (e.g. `NotFound` for "creator dropped the region"). +fn to_io_error(err: ShmemError) -> io::Error { + match err { + ShmemError::MapCreateFailed(code) | ShmemError::MapOpenFailed(code) => + { + #[expect( + clippy::cast_possible_wrap, + reason = "OS error codes are small positive integers; the cast is exact" + )] + io::Error::from_raw_os_error(code as i32) + } + other => io::Error::other(other), + } +} diff --git a/crates/fspy_shared_memory/src/lib.rs b/crates/fspy_shared_memory/src/lib.rs new file mode 100644 index 000000000..87dbaaa15 --- /dev/null +++ b/crates/fspy_shared_memory/src/lib.rs @@ -0,0 +1,180 @@ +//! Shared-memory abstraction used by fspy's IPC channel. +//! +//! On Linux, backs the region with a regular file in `std::env::temp_dir()` +//! and `mmap`s it. This avoids the `/dev/shm` size cap that POSIX `shm_open` +//! is subject to (e.g. Docker's 64 MiB default), which the previous +//! `shared_memory`-backed implementation was vulnerable to. +//! +//! On macOS and Windows, defers to the upstream `shared_memory` crate — +//! neither platform exhibits the size-cap bug and reusing battle-tested code +//! is the safer choice. + +use std::{io, num::NonZeroUsize}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +use linux::Inner; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod external; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use external::Inner; + +/// A shared memory region accessible across processes via [`os_id`](Self::os_id). +pub struct Shmem { + inner: Inner, +} + +impl Shmem { + /// Creates a new shared memory region of `size` bytes. The returned + /// [`os_id`](Self::os_id) can be passed to [`open`](Self::open) from + /// another process to attach to the same region. + /// + /// # Errors + /// Returns `io::ErrorKind::InvalidInput` if `size` is zero, or another + /// `io::Error` if the backing resource cannot be allocated. + pub fn create(size: usize) -> io::Result { + Inner::create(non_zero_size(size)?).map(|inner| Self { inner }) + } + + /// Opens an existing region previously created in another process. + /// + /// # Errors + /// Returns `io::ErrorKind::InvalidInput` if `size` is zero, or + /// `io::ErrorKind::NotFound` if the region's creator has dropped its + /// `Shmem` (and thus removed the backing). Other errors propagate from + /// the underlying OS calls. + pub fn open(os_id: &str, size: usize) -> io::Result { + Inner::open(os_id, non_zero_size(size)?).map(|inner| Self { inner }) + } + + /// Returns an opaque identifier that another process can pass to + /// [`open`](Self::open) to attach to this region. + #[must_use] + pub fn os_id(&self) -> &str { + self.inner.os_id() + } + + #[must_use] + pub fn as_ptr(&self) -> *mut u8 { + self.inner.as_ptr() + } + + #[must_use] + pub fn len(&self) -> usize { + self.inner.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Borrows the region as a byte slice. + /// + /// # Safety + /// The caller must ensure no concurrent mutators access the region for + /// the lifetime of the returned slice. Matches the contract of the + /// upstream `shared_memory::Shmem::as_slice`. + #[must_use] + pub unsafe fn as_slice(&self) -> &[u8] { + // SAFETY: caller upholds "no concurrent mutators" precondition; + // `as_ptr` and `len` describe a valid mmap region for `self`'s lifetime. + unsafe { std::slice::from_raw_parts(self.as_ptr(), self.len()) } + } +} + +fn non_zero_size(size: usize) -> io::Result { + NonZeroUsize::new(size).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "shared memory size must be non-zero") + }) +} + +#[cfg(test)] +mod tests { + use std::io; + + use assert2::assert; + use subprocess_test::command_for_fn; + + use super::*; + + const SIZE: usize = 64 * 1024; + + #[test] + fn smoke_create_open_same_process() { + let creator = Shmem::create(SIZE).expect("create"); + let opener = Shmem::open(creator.os_id(), SIZE).expect("open"); + + // SAFETY: this test is single-threaded; no concurrent mutators. + unsafe { creator.as_ptr().write(0x42) }; + // SAFETY: same as above. + let observed = unsafe { *opener.as_ptr() }; + assert!(observed == 0x42); + } + + #[test] + fn smoke_create_open_across_processes() { + let creator = Shmem::create(SIZE).expect("create"); + + // SAFETY: nothing else has a handle yet. + unsafe { creator.as_ptr().write(0xCD) }; + + let os_id = creator.os_id().to_owned(); + let cmd = command_for_fn!(os_id, |os_id: String| { + let opener = Shmem::open(&os_id, SIZE).expect("child open"); + // SAFETY: parent wrote then handed off via os_id; we read before writing. + let observed = unsafe { *opener.as_ptr() }; + assert!(observed == 0xCD); + // SAFETY: parent is blocked waiting for our exit, no concurrent reader. + unsafe { opener.as_ptr().write(0xEF) }; + }); + let status = std::process::Command::from(cmd).status().expect("spawn"); + assert!(status.success()); + + // SAFETY: child has exited (waited above); no concurrent writer. + let observed = unsafe { *creator.as_ptr() }; + assert!(observed == 0xEF); + } + + #[test] + fn open_nonexistent_returns_not_found() { + match Shmem::open("/tmp/fspy_shm_definitely_does_not_exist_xyz123", SIZE) { + Err(err) => assert!(err.kind() == io::ErrorKind::NotFound), + Ok(_) => panic!("expected NotFound error"), + } + } + + #[test] + fn create_zero_size_returns_invalid_input() { + match Shmem::create(0) { + Err(err) => assert!(err.kind() == io::ErrorKind::InvalidInput), + Ok(_) => panic!("expected InvalidInput error"), + } + } + + /// After the creator drops (removing the backing file on Linux), an + /// already-attached opener keeps its mapping valid — matches the + /// crash-resilience contract of the previous `shared_memory`-backed + /// implementation. + #[test] + fn opener_mapping_survives_creator_drop() { + let creator = Shmem::create(SIZE).expect("create"); + let opener = Shmem::open(creator.os_id(), SIZE).expect("open"); + let os_id = creator.os_id().to_owned(); + drop(creator); + + // SAFETY: only `opener` references the mapping now; no concurrent access. + unsafe { opener.as_ptr().write(0x99) }; + // SAFETY: same. + let observed = unsafe { *opener.as_ptr() }; + assert!(observed == 0x99); + + // A late opener arriving after the creator dropped sees the backing gone. + match Shmem::open(&os_id, SIZE) { + Err(err) => assert!(err.kind() == io::ErrorKind::NotFound), + Ok(_) => panic!("expected NotFound for late opener"), + } + } +} diff --git a/crates/fspy_shared_memory/src/linux.rs b/crates/fspy_shared_memory/src/linux.rs new file mode 100644 index 000000000..3a4d570f1 --- /dev/null +++ b/crates/fspy_shared_memory/src/linux.rs @@ -0,0 +1,96 @@ +use std::{ + env::temp_dir, fs::OpenOptions, io, num::NonZeroUsize, os::fd::AsFd, path::PathBuf, + ptr::NonNull, +}; + +use nix::sys::mman::{MapFlags, ProtFlags, mmap, munmap}; +use uuid::Uuid; + +pub struct Inner { + os_id: Box, + path: PathBuf, + addr: NonNull, + len: NonZeroUsize, + owns_file: bool, +} + +impl Inner { + pub fn create(size: NonZeroUsize) -> io::Result { + let path = temp_dir().join(format!("fspy_shm_{}", Uuid::new_v4().simple())); + let os_id: Box = path + .to_str() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "shared memory backing path must be valid UTF-8", + ) + })? + .into(); + + let file = OpenOptions::new().read(true).write(true).create_new(true).open(&path)?; + file.set_len(size.get() as u64)?; + + let addr = map(&file, size).inspect_err(|_| { + let _ = std::fs::remove_file(&path); + })?; + + Ok(Self { os_id, path, addr, len: size, owns_file: true }) + } + + pub fn open(os_id: &str, size: NonZeroUsize) -> io::Result { + let path = PathBuf::from(os_id); + let file = OpenOptions::new().read(true).write(true).open(&path)?; + let addr = map(&file, size)?; + Ok(Self { os_id: os_id.into(), path, addr, len: size, owns_file: false }) + } + + pub fn os_id(&self) -> &str { + &self.os_id + } + + #[expect( + clippy::missing_const_for_fn, + reason = "Mirrors the non-const macOS/Windows wrapper for API parity" + )] + pub fn as_ptr(&self) -> *mut u8 { + self.addr.as_ptr() + } + + #[expect( + clippy::missing_const_for_fn, + reason = "Mirrors the non-const macOS/Windows wrapper for API parity" + )] + pub fn len(&self) -> usize { + self.len.get() + } +} + +impl Drop for Inner { + fn drop(&mut self) { + // SAFETY: `self.addr` came from `mmap` with `self.len` bytes; the + // mapping is dropped exactly once because `Inner` owns it. + let _ = unsafe { munmap(self.addr.cast(), self.len.get()) }; + if self.owns_file { + let _ = std::fs::remove_file(&self.path); + } + } +} + +fn map(file: F, size: NonZeroUsize) -> io::Result> { + // SAFETY: `mmap` with a valid fd, non-zero length, and `MAP_SHARED` is + // sound. Treating the returned region as `*mut u8` is fine — it's plain + // bytes — and cross-process synchronization is the caller's responsibility + // per the `Shmem::as_slice` contract. + let ptr = unsafe { + mmap( + None, + size, + ProtFlags::PROT_READ | ProtFlags::PROT_WRITE, + MapFlags::MAP_SHARED, + file, + 0, + ) + } + .map_err(io::Error::from)?; + Ok(ptr.cast()) +}