From 9276b8f45db0e921e063e983fa18e14d5dd89ada Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:38:02 +0000 Subject: [PATCH 1/5] 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 387e4263..06c420f1 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 78ae6970..be6314bc 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 eb073812..150d567b 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/5] 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 06c420f1..3f78197f 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 be6314bc..c5e41645 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 150d567b..c96e537e 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/5] 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 c96e537e..34801621 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/5] 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 34801621..e4a34c6a 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/5] 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 e4a34c6a..a47e2188 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");