From 9276b8f45db0e921e063e983fa18e14d5dd89ada Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:38:02 +0000 Subject: [PATCH 1/6] test(fspy_shared): reproduce /dev/shm SIGBUS from issue #1453 Adds `channel_survives_constrained_dev_shm`, a regression test that reliably reproduces the SIGBUS reported in voidzero-dev/vite-plus#1453 on the shm_open-backed channel implementation. The test runs in a subprocess that enters an unprivileged user+mount namespace and remounts /dev/shm as a 1 MiB tmpfs, then writes past the cap through the real `channel()` API. Today the subprocess dies from SIGBUS when tmpfs can't back the next page; a backing store that escapes /dev/shm (e.g. memfd_create) will make the test pass without modification. No sudo is required; the test self-skips (exit 77) on hosts where unprivileged user namespaces are unavailable. --- Cargo.lock | 1 + crates/fspy_shared/Cargo.toml | 3 + crates/fspy_shared/src/ipc/channel/mod.rs | 170 ++++++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 387e42633..06c420f1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,6 +1295,7 @@ dependencies = [ "bstr", "bytemuck", "ctor", + "libc", "native_str", "os_str_bytes", "rustc-hash", diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index 78ae69704..be6314bc1 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -29,6 +29,9 @@ rustc-hash = { workspace = true } shared_memory = { workspace = true, features = ["logging"] } subprocess_test = { workspace = true } +[target.'cfg(target_os = "linux")'.dev-dependencies] +libc = { workspace = true } + [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..150d567b8 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -299,4 +299,174 @@ 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")] + #[expect(clippy::print_stderr, reason = "test diagnostics")] + 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!((), |(): ()| { + if let Err(err) = enter_userns_with_small_dev_shm() { + // Skip when unprivileged user namespaces aren't supported or + // are disabled on this host. Exit code 77 is the common + // "skipped" convention. + eprintln!("skipping: {err}"); + std::process::exit(77); + } + + let (conf, _receiver) = super::channel(CAPACITY).expect("channel creation"); + let sender = conf.sender().expect("sender creation"); + + // Write ~4 MiB of 4 KiB frames. First ~256 succeed within the + // 1 MiB tmpfs quota (minus header + alignment overhead); the + // next write faults on an un-backed page -> SIGBUS. + let frame_size = NonZeroUsize::new(4096).unwrap(); + let payload = [0xABu8; 4096]; + for i in 0..1024 { + let Some(mut frame) = sender.claim_frame(frame_size) else { + // Logical channel capacity exhausted before hitting the + // tmpfs cap — shouldn't happen with the sizes chosen + // here, but if it does the run is clean. + eprintln!("claim_frame returned None at iter {i}"); + break; + }; + frame.copy_from_slice(&payload); + } + }); + + let status = std::process::Command::from(cmd).status().unwrap(); + + if status.code() == Some(77) { + eprintln!( + "test channel_survives_constrained_dev_shm skipped: \ + unprivileged user namespaces unavailable" + ); + return; + } + + 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 = libc::SIGBUS, + ); + } + + /// 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. + #[cfg(target_os = "linux")] + fn enter_userns_with_small_dev_shm() -> Result<(), String> { + use std::ffi::CStr; + use std::io; + + let syscall_step = |name: &str, rc: libc::c_int| -> Result<(), String> { + if rc == 0 { + Ok(()) + } else { + Err(std::format!("{name}: {}", io::Error::last_os_error())) + } + }; + + let write_procfs_step = |path: &str, content: &str| -> Result<(), String> { + write_procfs(path, content).map_err(|err| std::format!("write {path}: {err}")) + }; + + // SAFETY: getuid/getgid are always safe and have no preconditions. + let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) }; + + // SAFETY: unshare takes a flags bitmask; no memory preconditions. + syscall_step("unshare(CLONE_NEWUSER|CLONE_NEWNS)", unsafe { + libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) + })?; + + // Inside the new user namespace the current process starts as + // "nobody" until the id maps are written. + write_procfs_step("/proc/self/uid_map", &std::format!("0 {uid} 1\n"))?; + // setgroups must be denied before gid_map can be written by an + // unprivileged process (see user_namespaces(7)). On some hosts the + // file is absent (older kernels, or a parent userns with setgroups + // permanently denied and not re-exposed); treat ENOENT as a no-op. + match write_procfs("/proc/self/setgroups", "deny") { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(std::format!("write /proc/self/setgroups: {err}")), + } + write_procfs_step("/proc/self/gid_map", &std::format!("0 {gid} 1\n"))?; + + // Make the root mount private recursively so tmpfs mounts inside + // this namespace don't propagate back to the host. + let none: &CStr = c"none"; + let root: &CStr = c"/"; + // SAFETY: arguments are valid C strings; other pointers are null + // which is explicitly allowed by mount(2) for these parameters. + syscall_step("mount --make-rprivate /", unsafe { + libc::mount( + none.as_ptr(), + root.as_ptr(), + std::ptr::null(), + libc::MS_REC | libc::MS_PRIVATE, + std::ptr::null(), + ) + })?; + + // 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. + let tmpfs: &CStr = c"tmpfs"; + let target: &CStr = c"/dev/shm"; + let opts: &CStr = c"size=1m"; + // SAFETY: all pointers reference valid NUL-terminated C strings. + syscall_step("mount tmpfs size=1m at /dev/shm", unsafe { + libc::mount( + tmpfs.as_ptr(), + target.as_ptr(), + tmpfs.as_ptr(), + 0, + opts.as_ptr().cast(), + ) + })?; + + Ok(()) + } } From 439275a41769b82114e9b3fb0c84140be2ca01b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:40:24 +0000 Subject: [PATCH 2/6] test(fspy_shared): use nix safe APIs for userns reproduction Replace raw libc calls (unshare, mount, getuid/getgid, SIGBUS) with nix's safe wrappers. Drops all unsafe blocks and the c-string literal juggling from enter_userns_with_small_dev_shm. --- Cargo.lock | 2 +- crates/fspy_shared/Cargo.toml | 2 +- crates/fspy_shared/src/ipc/channel/mod.rs | 75 +++++++++-------------- 3 files changed, 30 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06c420f1b..3f78197fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,8 +1295,8 @@ dependencies = [ "bstr", "bytemuck", "ctor", - "libc", "native_str", + "nix 0.30.1", "os_str_bytes", "rustc-hash", "shared_memory", diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index be6314bc1..c5e416455 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -30,7 +30,7 @@ shared_memory = { workspace = true, features = ["logging"] } subprocess_test = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] -libc = { workspace = true } +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 150d567b8..c96e537e3 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -378,7 +378,7 @@ mod tests { page allocation failed on a write to the shm-backed mapping.", status.code(), status.signal(), - sigbus = libc::SIGBUS, + sigbus = nix::sys::signal::Signal::SIGBUS as i32, ); } @@ -397,32 +397,22 @@ mod tests { /// before any threads are spawned in the current process. #[cfg(target_os = "linux")] fn enter_userns_with_small_dev_shm() -> Result<(), String> { - use std::ffi::CStr; use std::io; - let syscall_step = |name: &str, rc: libc::c_int| -> Result<(), String> { - if rc == 0 { - Ok(()) - } else { - Err(std::format!("{name}: {}", io::Error::last_os_error())) - } - }; - - let write_procfs_step = |path: &str, content: &str| -> Result<(), String> { - write_procfs(path, content).map_err(|err| std::format!("write {path}: {err}")) - }; + use nix::mount::{MsFlags, mount}; + use nix::sched::{CloneFlags, unshare}; + use nix::unistd::{Gid, Uid}; - // SAFETY: getuid/getgid are always safe and have no preconditions. - let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) }; + let uid = Uid::current().as_raw(); + let gid = Gid::current().as_raw(); - // SAFETY: unshare takes a flags bitmask; no memory preconditions. - syscall_step("unshare(CLONE_NEWUSER|CLONE_NEWNS)", unsafe { - libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) - })?; + unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS) + .map_err(|err| std::format!("unshare(CLONE_NEWUSER|CLONE_NEWNS): {err}"))?; // Inside the new user namespace the current process starts as // "nobody" until the id maps are written. - write_procfs_step("/proc/self/uid_map", &std::format!("0 {uid} 1\n"))?; + write_procfs("/proc/self/uid_map", &std::format!("0 {uid} 1\n")) + .map_err(|err| std::format!("write /proc/self/uid_map: {err}"))?; // setgroups must be denied before gid_map can be written by an // unprivileged process (see user_namespaces(7)). On some hosts the // file is absent (older kernels, or a parent userns with setgroups @@ -432,40 +422,31 @@ mod tests { Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => return Err(std::format!("write /proc/self/setgroups: {err}")), } - write_procfs_step("/proc/self/gid_map", &std::format!("0 {gid} 1\n"))?; + write_procfs("/proc/self/gid_map", &std::format!("0 {gid} 1\n")) + .map_err(|err| std::format!("write /proc/self/gid_map: {err}"))?; // Make the root mount private recursively so tmpfs mounts inside // this namespace don't propagate back to the host. - let none: &CStr = c"none"; - let root: &CStr = c"/"; - // SAFETY: arguments are valid C strings; other pointers are null - // which is explicitly allowed by mount(2) for these parameters. - syscall_step("mount --make-rprivate /", unsafe { - libc::mount( - none.as_ptr(), - root.as_ptr(), - std::ptr::null(), - libc::MS_REC | libc::MS_PRIVATE, - std::ptr::null(), - ) - })?; + mount( + None::<&str>, + "/", + None::<&str>, + MsFlags::MS_REC | MsFlags::MS_PRIVATE, + None::<&str>, + ) + .map_err(|err| std::format!("mount --make-rprivate /: {err}"))?; // 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. - let tmpfs: &CStr = c"tmpfs"; - let target: &CStr = c"/dev/shm"; - let opts: &CStr = c"size=1m"; - // SAFETY: all pointers reference valid NUL-terminated C strings. - syscall_step("mount tmpfs size=1m at /dev/shm", unsafe { - libc::mount( - tmpfs.as_ptr(), - target.as_ptr(), - tmpfs.as_ptr(), - 0, - opts.as_ptr().cast(), - ) - })?; + mount( + Some("tmpfs"), + "/dev/shm", + Some("tmpfs"), + MsFlags::empty(), + Some("size=1m"), + ) + .map_err(|err| std::format!("mount tmpfs size=1m at /dev/shm: {err}"))?; Ok(()) } From 33ce5e2b0030ebc5a0714804c8332da6809adaef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:43:47 +0000 Subject: [PATCH 3/6] test(fspy_shared): simplify repro to a single large frame fill Replace the 1024-iteration loop with a single 4 MiB `claim_frame` and `fill()`. The fill walks across the 1 MiB tmpfs boundary on its own and trips SIGBUS just as reliably. --- crates/fspy_shared/src/ipc/channel/mod.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index c96e537e3..34801621a 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -343,21 +343,12 @@ mod tests { let (conf, _receiver) = super::channel(CAPACITY).expect("channel creation"); let sender = conf.sender().expect("sender creation"); - // Write ~4 MiB of 4 KiB frames. First ~256 succeed within the - // 1 MiB tmpfs quota (minus header + alignment overhead); the - // next write faults on an un-backed page -> SIGBUS. - let frame_size = NonZeroUsize::new(4096).unwrap(); - let payload = [0xABu8; 4096]; - for i in 0..1024 { - let Some(mut frame) = sender.claim_frame(frame_size) else { - // Logical channel capacity exhausted before hitting the - // tmpfs cap — shouldn't happen with the sizes chosen - // here, but if it does the run is clean. - eprintln!("claim_frame returned None at iter {i}"); - break; - }; - frame.copy_from_slice(&payload); - } + // 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(); From f76856f4165ac30b618af3a966140f92ea67ed10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:47:30 +0000 Subject: [PATCH 4/6] test(fspy_shared): panic on userns setup failure instead of skipping Drop the exit-77 skip path around `enter_userns_with_small_dev_shm` and `.expect()` each step. Environment problems (userns disabled, missing procfs knobs, etc.) now fail the test loudly with a clear panic message pointing at the offending step, rather than silently turning green. --- crates/fspy_shared/src/ipc/channel/mod.rs | 47 ++++++----------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index 34801621a..e4a34c6af 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -321,7 +321,6 @@ mod tests { #[test] #[cfg(target_os = "linux")] #[cfg_attr(miri, ignore = "miri can't mmap or unshare")] - #[expect(clippy::print_stderr, reason = "test diagnostics")] fn channel_survives_constrained_dev_shm() { use std::os::unix::process::ExitStatusExt; @@ -332,13 +331,7 @@ mod tests { const CAPACITY: usize = 16 * 1024 * 1024; let cmd = command_for_fn!((), |(): ()| { - if let Err(err) = enter_userns_with_small_dev_shm() { - // Skip when unprivileged user namespaces aren't supported or - // are disabled on this host. Exit code 77 is the common - // "skipped" convention. - eprintln!("skipping: {err}"); - std::process::exit(77); - } + enter_userns_with_small_dev_shm(); let (conf, _receiver) = super::channel(CAPACITY).expect("channel creation"); let sender = conf.sender().expect("sender creation"); @@ -353,14 +346,6 @@ mod tests { let status = std::process::Command::from(cmd).status().unwrap(); - if status.code() == Some(77) { - eprintln!( - "test channel_survives_constrained_dev_shm skipped: \ - unprivileged user namespaces unavailable" - ); - return; - } - assert!( status.success(), "channel writes should survive a constrained /dev/shm, but the \ @@ -385,11 +370,11 @@ mod tests { /// 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. + /// 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() -> Result<(), String> { - use std::io; - + fn enter_userns_with_small_dev_shm() { use nix::mount::{MsFlags, mount}; use nix::sched::{CloneFlags, unshare}; use nix::unistd::{Gid, Uid}; @@ -398,23 +383,17 @@ mod tests { let gid = Gid::current().as_raw(); unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS) - .map_err(|err| std::format!("unshare(CLONE_NEWUSER|CLONE_NEWNS): {err}"))?; + .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")) - .map_err(|err| std::format!("write /proc/self/uid_map: {err}"))?; + .expect("write /proc/self/uid_map"); // setgroups must be denied before gid_map can be written by an - // unprivileged process (see user_namespaces(7)). On some hosts the - // file is absent (older kernels, or a parent userns with setgroups - // permanently denied and not re-exposed); treat ENOENT as a no-op. - match write_procfs("/proc/self/setgroups", "deny") { - Ok(()) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => return Err(std::format!("write /proc/self/setgroups: {err}")), - } + // unprivileged process (see user_namespaces(7)). + write_procfs("/proc/self/setgroups", "deny").expect("write /proc/self/setgroups"); write_procfs("/proc/self/gid_map", &std::format!("0 {gid} 1\n")) - .map_err(|err| std::format!("write /proc/self/gid_map: {err}"))?; + .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. @@ -425,7 +404,7 @@ mod tests { MsFlags::MS_REC | MsFlags::MS_PRIVATE, None::<&str>, ) - .map_err(|err| std::format!("mount --make-rprivate /: {err}"))?; + .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 @@ -437,8 +416,6 @@ mod tests { MsFlags::empty(), Some("size=1m"), ) - .map_err(|err| std::format!("mount tmpfs size=1m at /dev/shm: {err}"))?; - - Ok(()) + .expect("mount tmpfs size=1m at /dev/shm"); } } From 48448ebfb093376717c59b5c379a9f75d099b088 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:51:08 +0000 Subject: [PATCH 5/6] test(fspy_shared): tolerate absent /proc/self/setgroups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the ENOENT match around the setgroups write that the previous commit dropped along with the userns-skip path. The two cases are different: a missing /proc/self/setgroups means setgroups(2) is already permanently denied in an ancestor user namespace, so the gid_map precondition documented in user_namespaces(7) is already satisfied — not a sign that the environment can't run the test. Everything else keeps `.expect()`, so a genuinely unsupported environment (unshare denied, other procfs writes failing) still panics loudly. --- crates/fspy_shared/src/ipc/channel/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index e4a34c6af..a47e21886 100644 --- a/crates/fspy_shared/src/ipc/channel/mod.rs +++ b/crates/fspy_shared/src/ipc/channel/mod.rs @@ -389,9 +389,16 @@ mod tests { // "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 gid_map can be written by an - // unprivileged process (see user_namespaces(7)). - write_procfs("/proc/self/setgroups", "deny").expect("write /proc/self/setgroups"); + // 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"); From dfcd49a3089005429ea9af09170108063f866e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 06:59:09 +0000 Subject: [PATCH 6/6] fix(fspy_shared): back IPC channel mapping with a regular file on Linux Replace the `shared_memory` crate, whose Linux backing is POSIX `shm_open` in `/dev/shm`, with a thin in-house `fspy_shared_memory` crate. On Linux the new crate `mmap`s a regular file in `temp_dir()`, escaping the `/dev/shm` size cap that triggers SIGBUS under Docker's 64 MiB default (voidzero-dev/vite-plus#1453). macOS and Windows continue to use the existing `shared_memory` crate. The `channel_survives_constrained_dev_shm` regression test now passes. https://claude.ai/code/session_01CjeJ3LgNb7uxeN2E1VMpY8 --- CHANGELOG.md | 1 + Cargo.lock | 15 +- Cargo.toml | 1 + crates/fspy_shared/Cargo.toml | 4 +- crates/fspy_shared/src/ipc/channel/mod.rs | 57 ++---- crates/fspy_shared/src/ipc/channel/shm_io.rs | 10 +- crates/fspy_shared_memory/.clippy.toml | 1 + crates/fspy_shared_memory/Cargo.toml | 29 +++ crates/fspy_shared_memory/src/external.rs | 52 ++++++ crates/fspy_shared_memory/src/lib.rs | 180 +++++++++++++++++++ crates/fspy_shared_memory/src/linux.rs | 96 ++++++++++ 11 files changed, 393 insertions(+), 53 deletions(-) create mode 120000 crates/fspy_shared_memory/.clippy.toml create mode 100644 crates/fspy_shared_memory/Cargo.toml create mode 100644 crates/fspy_shared_memory/src/external.rs create mode 100644 crates/fspy_shared_memory/src/lib.rs create mode 100644 crates/fspy_shared_memory/src/linux.rs 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 3f78197fc..b539c86b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,11 +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", @@ -1308,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" @@ -3145,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 c5e416455..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,7 +26,7 @@ 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] diff --git a/crates/fspy_shared/src/ipc/channel/mod.rs b/crates/fspy_shared/src/ipc/channel/mod.rs index a47e21886..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) }; @@ -375,9 +354,11 @@ mod tests { /// for this reproduction. #[cfg(target_os = "linux")] fn enter_userns_with_small_dev_shm() { - use nix::mount::{MsFlags, mount}; - use nix::sched::{CloneFlags, unshare}; - use nix::unistd::{Gid, Uid}; + use nix::{ + mount::{MsFlags, mount}, + sched::{CloneFlags, unshare}, + unistd::{Gid, Uid}, + }; let uid = Uid::current().as_raw(); let gid = Gid::current().as_raw(); @@ -404,25 +385,13 @@ mod tests { // 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 /"); + 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"); + 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()) +}