From 00deef21f9c32b068e36bc55bca294dcb5bb2d00 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 10 Aug 2026 12:46:05 +0800 Subject: [PATCH] feat(sigsafe): iterate process arguments and environment Co-authored-by: GPT-5 Codex --- Cargo.lock | 12 + Cargo.toml | 2 +- crates/fspy/Cargo.toml | 2 +- crates/fspy_preload_unix/Cargo.toml | 2 +- crates/fspy_preload_unix/src/client/mod.rs | 9 +- crates/fspy_shared/Cargo.toml | 2 +- crates/fspy_shared_unix/Cargo.toml | 3 +- crates/fspy_shared_unix/src/payload.rs | 25 +- crates/sigsafe/Cargo.toml | 7 + crates/sigsafe/README.md | 1 + crates/sigsafe/src/env/linux.rs | 348 +++++++++++++++++++++ crates/sigsafe/src/env/mac/current.rs | 96 ++++++ crates/sigsafe/src/env/mac/mod.rs | 5 + crates/sigsafe/src/env/mac/thin.rs | 163 ++++++++++ crates/sigsafe/src/env/mod.rs | 30 ++ crates/sigsafe/src/lib.rs | 3 +- 16 files changed, 691 insertions(+), 19 deletions(-) create mode 100644 crates/sigsafe/src/env/linux.rs create mode 100644 crates/sigsafe/src/env/mac/current.rs create mode 100644 crates/sigsafe/src/env/mac/mod.rs create mode 100644 crates/sigsafe/src/env/mac/thin.rs create mode 100644 crates/sigsafe/src/env/mod.rs diff --git a/Cargo.lock b/Cargo.lock index c29b1d970..5fcb11c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atoi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.6.1" @@ -1391,6 +1400,7 @@ dependencies = [ "memmap2", "nix 0.31.2", "phf 0.13.1", + "sigsafe", "stackalloc", "wincode", ] @@ -3429,6 +3439,8 @@ dependencies = [ name = "sigsafe" version = "0.0.0" dependencies = [ + "atoi", + "bstr", "libc", "rustix", "syscalls", diff --git a/Cargo.toml b/Cargo.toml index aaf77a637..06f709094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ wincode = "0.6.0" bindgen = "0.72.1" bitflags = "2.10.0" brush-parser = "0.4.0" -bstr = { version = "1.12.0", default-features = false, features = ["alloc", "std"] } +bstr = { version = "1.12.0", default-features = false } bump-scope = { version = "2", default-features = false, features = ["allocator-api2-02"] } bumpalo = { version = "3.17.0", features = ["collections"] } bytemuck = { version = "1.23.0", features = ["extern_crate_alloc", "must_cast"] } diff --git a/crates/fspy/Cargo.toml b/crates/fspy/Cargo.toml index 38b9ffb51..9413a9025 100644 --- a/crates/fspy/Cargo.toml +++ b/crates/fspy/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] wincode = { workspace = true } -bstr = { workspace = true, default-features = false } +bstr = { workspace = true, features = ["alloc", "std"] } bumpalo = { workspace = true } derive_more = { workspace = true, features = ["debug"] } materialized_artifact = { workspace = true } diff --git a/crates/fspy_preload_unix/Cargo.toml b/crates/fspy_preload_unix/Cargo.toml index 99ad99610..c274e14fd 100644 --- a/crates/fspy_preload_unix/Cargo.toml +++ b/crates/fspy_preload_unix/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] allocator-api2 = { workspace = true, features = ["alloc"] } anyhow = { workspace = true } wincode = { workspace = true } -bstr = { workspace = true, default-features = false } +bstr = { workspace = true, features = ["alloc", "std"] } ctor = { workspace = true } fspy_shared = { workspace = true } fspy_shared_unix = { workspace = true } diff --git a/crates/fspy_preload_unix/src/client/mod.rs b/crates/fspy_preload_unix/src/client/mod.rs index 71c749f11..582e857f3 100644 --- a/crates/fspy_preload_unix/src/client/mod.rs +++ b/crates/fspy_preload_unix/src/client/mod.rs @@ -40,10 +40,10 @@ impl Client { reason = "preload library intentionally uses stderr for error reporting" )] #[cfg(not(test))] - fn from_env() -> Self { + fn from_env(envs: impl Iterator) -> Self { use fspy_shared_unix::payload::decode_payload_from_env; - let encoded_payload = decode_payload_from_env().unwrap(); + let encoded_payload = decode_payload_from_env(envs).unwrap(); let ipc_sender = match encoded_payload.payload.ipc_channel_conf.sender() { Ok(sender) => Some(sender), @@ -155,5 +155,8 @@ pub unsafe fn handle_open(path: impl ToAbsolutePath, mode: impl ToAccessMode) { #[cfg(not(test))] #[ctor::ctor(unsafe)] fn init_client() { - CLIENT.set(Client::from_env()).unwrap(); + // SAFETY: the ctor only reads the process environment while constructing + // the client and does not retain borrowed environment views. + let current = unsafe { sigsafe::env::current() }.unwrap(); + CLIENT.set(Client::from_env(current.envs())).unwrap(); } diff --git a/crates/fspy_shared/Cargo.toml b/crates/fspy_shared/Cargo.toml index 403a11f38..67f8ef640 100644 --- a/crates/fspy_shared/Cargo.toml +++ b/crates/fspy_shared/Cargo.toml @@ -9,7 +9,7 @@ publish = false wincode = { workspace = true, features = ["derive"] } bitflags = { workspace = true } bumpalo = { workspace = true } -bstr = { workspace = true } +bstr = { workspace = true, features = ["alloc", "std"] } bytemuck = { workspace = true, features = ["must_cast", "derive"] } fspy_shm = { workspace = true } native_str = { workspace = true } diff --git a/crates/fspy_shared_unix/Cargo.toml b/crates/fspy_shared_unix/Cargo.toml index 38b9301bb..d2e939123 100644 --- a/crates/fspy_shared_unix/Cargo.toml +++ b/crates/fspy_shared_unix/Cargo.toml @@ -9,9 +9,10 @@ publish = false anyhow = { workspace = true } base64 = { workspace = true } wincode = { workspace = true, features = ["derive"] } -bstr = { workspace = true } +bstr = { workspace = true, features = ["alloc", "std"] } fspy_shared = { workspace = true } nix = { workspace = true, features = ["fs"] } +sigsafe = { workspace = true } stackalloc = { workspace = true } [dev-dependencies] diff --git a/crates/fspy_shared_unix/src/payload.rs b/crates/fspy_shared_unix/src/payload.rs index 5267bd42e..e42238ac9 100644 --- a/crates/fspy_shared_unix/src/payload.rs +++ b/crates/fspy_shared_unix/src/payload.rs @@ -1,5 +1,3 @@ -use std::os::unix::ffi::OsStringExt; - use base64::{Engine as _, prelude::BASE64_STANDARD_NO_PAD}; use bstr::BString; #[cfg(not(target_env = "musl"))] @@ -53,19 +51,26 @@ pub fn encode_payload(payload: Payload) -> EncodedPayload { EncodedPayload { payload, encoded_string: encoded_string.into() } } -/// Decodes the fspy payload from the environment variable +/// Decodes the fspy payload from an iterator over environment entries. /// /// # Errors /// -/// Returns an error if: -/// - The environment variable is not found -/// - The base64 decoding fails -/// - The deserialization fails -pub fn decode_payload_from_env() -> anyhow::Result { - let Some(encoded_string) = std::env::var_os(PAYLOAD_ENV_NAME) else { +/// Returns an error if the payload environment variable is missing, base64 +/// decoding fails, or deserialization fails. +pub fn decode_payload_from_env( + mut envs: impl Iterator, +) -> anyhow::Result { + let Some(encoded_string) = envs.find_map(|(name, value)| { + if AsRef::<[u8]>::as_ref(name) == PAYLOAD_ENV_NAME.as_bytes() { + value.map(|value| BString::from(value.as_bytes())) + } else { + None + } + }) else { anyhow::bail!("Environment variable '{PAYLOAD_ENV_NAME}' not found"); }; - decode_payload(encoded_string.into_vec().into()) + + decode_payload(encoded_string) } fn decode_payload(encoded_string: BString) -> anyhow::Result { diff --git a/crates/sigsafe/Cargo.toml b/crates/sigsafe/Cargo.toml index 467402133..fea461676 100644 --- a/crates/sigsafe/Cargo.toml +++ b/crates/sigsafe/Cargo.toml @@ -7,6 +7,11 @@ publish = false [lib] doctest = false +[dependencies] +# Only the borrowed `BStr` type is used. Keeping every feature disabled makes +# the environment iterator usable without `alloc` or `std`. +bstr = { workspace = true } + [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } @@ -21,6 +26,8 @@ rustix = { workspace = true, features = ["param"] } # The compile-time backend check in lib.rs needs a `linux_raw`-gated rustix # item to reference; `runtime` is the module that has one. [target.'cfg(target_os = "linux")'.dependencies] +# Parsing remains allocation-free and no-std; `atoi` enables `std` by default. +atoi = { version = "3.1.0", default-features = false } rustix = { workspace = true, features = ["runtime"] } syscalls = { workspace = true } diff --git a/crates/sigsafe/README.md b/crates/sigsafe/README.md index 9a8a4480a..e708d0da0 100644 --- a/crates/sigsafe/README.md +++ b/crates/sigsafe/README.md @@ -29,6 +29,7 @@ rustix can be built with a libc backend instead of raw syscalls, and anything in Functions whose rustix implementation already meets the rules are re-exposed as-is; being listed in a module here is what marks a call as allowed, and the backend check above is what keeps that true. - `mm` — anonymous memory mappings: `mmap_anonymous`, `munmap`. +- `env` — allocation-free iteration over process arguments and environment entries. - `fs` — caller-buffer filesystem operations: `getcwd`, plus macOS `fcntl_getpath`. - `param` — `page_size`. diff --git a/crates/sigsafe/src/env/linux.rs b/crates/sigsafe/src/env/linux.rs new file mode 100644 index 000000000..47fad46cc --- /dev/null +++ b/crates/sigsafe/src/env/linux.rs @@ -0,0 +1,348 @@ +use core::{ffi::CStr as CoreCStr, num::NonZeroUsize, ptr, slice}; + +use atoi::FromRadix10Checked as _; +use bstr::{BStr, ByteSlice as _}; +use rustix::fs::{Mode, OFlags}; + +use super::Entry; +use crate::{CStr, CWD, Errno, Fat, Result}; + +#[derive(Clone, Copy)] +struct Bounds { + arg_start: usize, + arg_end: usize, + env_start: usize, + env_end: usize, +} + +struct RangeIter<'a> { + remaining: &'a [u8], +} + +impl<'a> RangeIter<'a> { + const fn new(range: &'a [u8]) -> Self { + Self { remaining: range } + } +} + +impl<'a> Iterator for RangeIter<'a> { + type Item = CStr<'a, Fat>; + + fn next(&mut self) -> Option { + let Some(nul) = self.remaining.iter().position(|byte| *byte == 0) else { + self.remaining = &[]; + return None; + }; + let entry_len = NonZeroUsize::new(nul.checked_add(1)?)?; + let (entry, remaining) = self.remaining.split_at_checked(entry_len.get())?; + self.remaining = remaining; + + // SAFETY: splitting at the first NUL produces a nonempty slice with + // exactly one trailing NUL. + Some(unsafe { CStr::from_bytes_with_nul_unchecked(entry) }) + } +} + +fn split_fat(entry: CStr<'static, Fat>) -> Entry { + let Some((name, value)) = entry.as_bytes_with_nul().split_once_str(b"=") else { + return (BStr::new(entry.as_bytes()), None); + }; + + // SAFETY: splitting preserves the single trailing NUL. + let value = unsafe { CStr::from_bytes_with_nul_unchecked(value) }; + (BStr::new(name), Some(value)) +} + +/// A snapshot of the process argument and environment string ranges. +pub struct Current { + args: &'static [u8], + envs: &'static [u8], +} + +impl Current { + /// Converts the kernel-published address bounds into borrowed slices. + /// + /// # Safety + /// + /// Both ranges must be immutable and readable for the lifetime of every + /// returned view. + const unsafe fn from_bounds(bounds: Bounds) -> Result { + // SAFETY: upheld by this function's caller. + let args = match unsafe { slice_from_range(bounds.arg_start, bounds.arg_end) } { + Ok(args) => args, + Err(error) => return Err(error), + }; + // SAFETY: upheld by this function's caller. + let envs = match unsafe { slice_from_range(bounds.env_start, bounds.env_end) } { + Ok(envs) => envs, + Err(error) => return Err(error), + }; + Ok(Self { args, envs }) + } + + /// Returns a fresh iterator over the process arguments. + #[must_use] + pub const fn args(&self) -> FatArgs { + FatArgs { inner: RangeIter::new(self.args) } + } + + /// Returns a fresh iterator over the process environment. + #[must_use] + pub const fn envs(&self) -> FatEnvs { + FatEnvs { inner: RangeIter::new(self.envs) } + } +} + +/// Converts one kernel-published address range into a borrowed slice. +/// +/// # Safety +/// +/// The nonempty range must belong to one immutable, readable allocation that +/// remains valid for `'a`. +const unsafe fn slice_from_range<'a>(start: usize, end: usize) -> Result<&'a [u8]> { + let len = match end.checked_sub(start) { + Some(len) if len <= isize::MAX.cast_unsigned() => len, + _ => return Err(Errno::INVAL), + }; + let Some(len) = NonZeroUsize::new(len) else { + return Ok(&[]); + }; + let Some(start) = NonZeroUsize::new(start) else { + return Err(Errno::INVAL); + }; + + let start = ptr::with_exposed_provenance::(start.get()); + // SAFETY: the caller guarantees that the complete address range is one + // immutable, readable allocation for the requested lifetime. + Ok(unsafe { slice::from_raw_parts(start, len.get()) }) +} + +/// An iterator over process arguments as counted C strings. +pub struct FatArgs { + inner: RangeIter<'static>, +} + +impl Iterator for FatArgs { + type Item = CStr<'static, Fat>; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +/// An iterator over process environment entries as counted C strings. +pub struct FatEnvs { + inner: RangeIter<'static>, +} + +impl Iterator for FatEnvs { + type Item = Entry; + + fn next(&mut self) -> Option { + self.inner.next().map(split_fat) + } +} + +/// Snapshots the process argument and environment ranges. +/// +/// This opens and reads `/proc/self/stat` once into fixed stack storage and +/// parses `arg_start`, `arg_end`, `env_start`, and `env_end` together. It does +/// not allocate, and rustix's raw Linux backend makes the file operations +/// direct syscalls. Iterators subsequently created from the snapshot perform +/// no syscalls. +/// +/// # Errors +/// +/// Returns [`crate::Error`] if `/proc/self/stat` cannot be opened or read, does +/// not fit in the fixed buffer, or does not contain valid string bounds. +/// +/// # Safety +/// +/// Until the snapshot and every view yielded from it are discarded, the +/// caller must ensure that its published argument and environment ranges +/// remain mapped, readable, and immutable. In particular, no thread may +/// rewrite the process title or original environment strings, change the +/// bounds with `PR_SET_MM`, or execute a new image. +pub unsafe fn current() -> Result { + let bounds = read_bounds()?; + // SAFETY: the caller guarantees that both published address ranges remain + // valid, readable, and immutable for every returned view. + unsafe { Current::from_bounds(bounds) } +} + +fn read_bounds() -> Result { + const STAT_PATH: &CoreCStr = c"/proc/self/stat"; + const STAT_CAPACITY: usize = 4096; + + let fd = rustix::fs::openat(CWD, STAT_PATH, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())?; + let mut stat = [0; STAT_CAPACITY]; + let mut initialized = 0; + + loop { + let Some(remaining) = stat.get_mut(initialized..) else { + return Err(Errno::OVERFLOW); + }; + if remaining.is_empty() { + return Err(Errno::OVERFLOW); + } + + let Some(read) = NonZeroUsize::new(rustix::io::read(&fd, remaining)?) else { + break; + }; + initialized = initialized.checked_add(read.get()).ok_or(Errno::OVERFLOW)?; + } + + parse_bounds(&stat[..initialized]) +} + +fn parse_bounds(stat: &[u8]) -> Result { + // `comm` (field 2) may itself contain spaces, newlines, and `)`. The + // kernel-added delimiter is the last `)` because every later field is + // numeric except for the one-byte process state. + let comm_end = stat.iter().rposition(|byte| *byte == b')').ok_or(Errno::INVAL)?; + let (_, comm_and_fields) = stat.split_at_checked(comm_end).ok_or(Errno::INVAL)?; + let (_, fields) = comm_and_fields.split_first().ok_or(Errno::INVAL)?; + let mut fields = fields.split(u8::is_ascii_whitespace).filter(|field| !field.is_empty()); + + // With field 3 at index zero, arg_start (field 48) is index 45, followed + // by arg_end, env_start, and env_end. + let arg_start = fields.nth(45).ok_or(Errno::INVAL)?; + let arg_end = fields.next().ok_or(Errno::INVAL)?; + let env_start = fields.next().ok_or(Errno::INVAL)?; + let env_end = fields.next().ok_or(Errno::INVAL)?; + Ok(Bounds { + arg_start: parse_usize(arg_start)?, + arg_end: parse_usize(arg_end)?, + env_start: parse_usize(env_start)?, + env_end: parse_usize(env_end)?, + }) +} + +fn parse_usize(bytes: &[u8]) -> Result { + let (value, used) = usize::from_radix_10_checked(bytes); + let value = value.ok_or(Errno::OVERFLOW)?; + let used = NonZeroUsize::new(used).ok_or(Errno::INVAL)?; + if used.get() != bytes.len() { + return Err(Errno::INVAL); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iterates_argument_and_environment_ranges() { + static ARGS: &[u8] = b"program\0--flag\0"; + static ENVS: &[u8] = b"FIRST=one\0INVALID\0EMPTY=\0LAST=a=b\0"; + let current = Current { args: ARGS, envs: ENVS }; + + let mut args = current.args(); + assert_eq!(args.next().unwrap().as_bytes(), b"program"); + assert_eq!(args.next().unwrap().as_bytes(), b"--flag"); + assert!(args.next().is_none()); + + let mut envs = current.envs(); + let (name, value) = envs.next().unwrap(); + assert_eq!(name.as_bytes(), b"FIRST"); + assert_eq!(value.unwrap().as_bytes(), b"one"); + + let (name, value) = envs.next().unwrap(); + assert_eq!(name.as_bytes(), b"INVALID"); + assert!(value.is_none()); + + let (name, value) = envs.next().unwrap(); + assert_eq!(name.as_bytes(), b"EMPTY"); + assert_eq!(value.unwrap().as_bytes(), b""); + + let (name, value) = envs.next().unwrap(); + assert_eq!(name.as_bytes(), b"LAST"); + assert_eq!(value.unwrap().as_bytes(), b"a=b"); + assert!(envs.next().is_none()); + } + + #[test] + fn entry_splits_only_at_the_first_equals() { + // SAFETY: this static byte string contains exactly one trailing NUL. + let entry = unsafe { CStr::::from_bytes_with_nul_unchecked(b"NAME=a=b\0") }; + let (name, value) = split_fat(entry); + let value = value.unwrap(); + + assert_eq!(name.as_bytes(), b"NAME"); + assert_eq!(value.as_bytes(), b"a=b"); + assert_eq!(value.as_bytes_with_nul(), b"a=b\0"); + } + + #[test] + fn entry_accepts_an_empty_value() { + // SAFETY: this static byte string contains exactly one trailing NUL. + let entry = unsafe { CStr::::from_bytes_with_nul_unchecked(b"EMPTY=\0") }; + let (name, value) = split_fat(entry); + let value = value.unwrap(); + + assert_eq!(name.as_bytes(), b"EMPTY"); + assert_eq!(value.as_bytes_with_nul(), b"\0"); + } + + #[test] + fn entry_without_equals_has_no_value() { + // SAFETY: this static byte string contains exactly one trailing NUL. + let entry = unsafe { CStr::::from_bytes_with_nul_unchecked(b"INVALID\0") }; + let (name, value) = split_fat(entry); + + assert_eq!(name.as_bytes(), b"INVALID"); + assert!(value.is_none()); + } + + #[test] + fn parses_current_process_bounds() { + let bounds = read_bounds().unwrap(); + assert!(bounds.arg_start <= bounds.arg_end); + assert!(bounds.env_start <= bounds.env_end); + } + + #[test] + fn parses_bounds_after_a_multiline_comm_containing_a_parenthesis() { + let mut stat = b"123 (before)\nafter) R".to_vec(); + for _ in 4..48 { + stat.extend_from_slice(b" 0"); + } + stat.extend_from_slice(b" 100 200 300 400 0\n"); + + let bounds = parse_bounds(&stat).unwrap(); + assert_eq!((bounds.arg_start, bounds.arg_end), (100, 200)); + assert_eq!((bounds.env_start, bounds.env_end), (300, 400)); + } + + #[test] + fn parses_only_complete_in_range_decimal_fields() { + assert_eq!(parse_usize(b"42"), Ok(42)); + assert_eq!(parse_usize(b""), Err(Errno::INVAL)); + assert_eq!(parse_usize(b"42x"), Err(Errno::INVAL)); + assert_eq!( + parse_usize(b"9999999999999999999999999999999999999999999"), + Err(Errno::OVERFLOW) + ); + } + + #[test] + fn process_snapshot_contains_argv_zero_and_path() { + // SAFETY: this test does not mutate the process arguments or + // environment while its snapshot or borrowed entries are live. + let current = unsafe { current().unwrap() }; + + let argv_zero = current.args().next().unwrap(); + assert_eq!(argv_zero.as_bytes(), std::env::args_os().next().unwrap().as_encoded_bytes()); + + let path = current.envs().find(|(name, _)| name.as_bytes() == b"PATH").unwrap().1.unwrap(); + assert_eq!(path.as_bytes(), std::env::var_os("PATH").unwrap().as_encoded_bytes()); + } + + #[test] + fn rejects_an_unterminated_range() { + static ARGS: &[u8] = b"program"; + let current = Current { args: ARGS, envs: &[] }; + assert!(current.args().next().is_none()); + } +} diff --git a/crates/sigsafe/src/env/mac/current.rs b/crates/sigsafe/src/env/mac/current.rs new file mode 100644 index 000000000..1fd696da2 --- /dev/null +++ b/crates/sigsafe/src/env/mac/current.rs @@ -0,0 +1,96 @@ +use super::thin::{ThinArgs, ThinEnvs, args as thin_args, envs as thin_envs}; +use crate::{CStr, Fat, Result, env::Entry}; + +/// A snapshot of the macOS thin argument and environment iterators. +pub struct Current { + args: ThinArgs, + envs: ThinEnvs, +} + +impl Current { + /// Returns a fresh fat C-string iterator over the process arguments. + #[must_use] + pub fn args(&self) -> FatArgs { + FatArgs { inner: self.args.clone() } + } + + /// Returns a fresh fat C-string iterator over the process environment. + #[must_use] + pub fn envs(&self) -> FatEnvs { + FatEnvs { inner: self.envs.clone() } + } +} + +/// An iterator over process arguments as counted C strings. +pub struct FatArgs { + inner: ThinArgs, +} + +impl Iterator for FatArgs { + type Item = CStr<'static, Fat>; + + fn next(&mut self) -> Option { + self.inner.next().map(CStr::count) + } +} + +/// An iterator over process environment entries as counted C strings. +pub struct FatEnvs { + inner: ThinEnvs, +} + +impl Iterator for FatEnvs { + type Item = Entry; + + fn next(&mut self) -> Option { + self.inner.next().map(|(name, value)| (name, value.map(CStr::count))) + } +} + +/// Snapshots both macOS thin iterators for portable fat iteration. +/// +/// [`Current::args`] and [`Current::envs`] have the same item and error types +/// as their Linux counterparts. Construction is currently infallible on +/// macOS; the result preserves that portable signature. +/// +/// # Errors +/// +/// This function does not currently return an error on macOS. +/// +/// # Safety +/// +/// Until the snapshot and every view yielded from it are discarded, the +/// caller must ensure that the argument and environment pointer arrays and +/// their strings remain mapped, readable, and immutable and that no new image +/// is executed. +#[expect( + clippy::unnecessary_wraps, + reason = "the return type deliberately matches Linux's fallible current() API" +)] +pub unsafe fn current() -> Result { + // SAFETY: the caller accepts both thin iterators' validity requirements. + let args = unsafe { thin_args() }; + // SAFETY: as above for the environment iterator. + let envs = unsafe { thin_envs() }; + Ok(Current { args, envs }) +} + +#[cfg(test)] +mod tests { + use bstr::ByteSlice as _; + + use super::*; + + #[test] + fn current_has_portable_fat_iterators() { + // SAFETY: this test does not mutate the argument or environment arrays + // while their snapshot or borrowed entries are live. + let current = unsafe { current().unwrap() }; + + let argv_zero = current.args().next().unwrap(); + assert_eq!(argv_zero.as_bytes(), std::env::args_os().next().unwrap().as_encoded_bytes()); + + let path = current.envs().find(|(name, _)| name.as_bytes() == b"PATH").unwrap().1.unwrap(); + assert_eq!(path.as_bytes(), std::env::var_os("PATH").unwrap().as_encoded_bytes()); + } +} diff --git a/crates/sigsafe/src/env/mac/mod.rs b/crates/sigsafe/src/env/mac/mod.rs new file mode 100644 index 000000000..8ff271c09 --- /dev/null +++ b/crates/sigsafe/src/env/mac/mod.rs @@ -0,0 +1,5 @@ +mod current; +mod thin; + +pub use current::{Current, FatArgs, FatEnvs, current}; +pub use thin::{ThinArgs, ThinEnvs, args, envs}; diff --git a/crates/sigsafe/src/env/mac/thin.rs b/crates/sigsafe/src/env/mac/thin.rs new file mode 100644 index 000000000..2b97cf151 --- /dev/null +++ b/crates/sigsafe/src/env/mac/thin.rs @@ -0,0 +1,163 @@ +use core::{ffi::c_char, ptr::NonNull, slice}; + +use bstr::BStr; + +use crate::{CStr, Thin, env::Entry}; + +#[derive(Clone)] +struct PointerIter { + current: *const *const c_char, +} + +impl Iterator for PointerIter { + type Item = CStr<'static, Thin>; + + fn next(&mut self) -> Option { + // SAFETY: the constructor's caller guarantees that the pointer array + // remains valid and ends in a null pointer. + let entry = NonNull::new(unsafe { self.current.read() }.cast_mut())?; + + // SAFETY: another pointer slot, possibly the terminating null pointer, + // follows every non-null entry. + self.current = unsafe { self.current.add(1) }; + // SAFETY: every non-null pointer in these arrays names an immutable, + // NUL-terminated string under the constructor's caller contract. + Some(unsafe { CStr::::from_non_null(entry) }) + } +} + +/// An iterator over process arguments as thin C strings. +#[derive(Clone)] +pub struct ThinArgs { + inner: PointerIter, +} + +impl Iterator for ThinArgs { + type Item = CStr<'static, Thin>; + + fn next(&mut self) -> Option { + self.inner.next() + } +} + +/// An iterator over process environment entries as thin C strings. +#[derive(Clone)] +pub struct ThinEnvs { + inner: PointerIter, +} + +impl Iterator for ThinEnvs { + type Item = Entry; + + fn next(&mut self) -> Option { + self.inner.next().map(split_thin) + } +} + +// Splitting cannot fail because `entry` is already a valid C string. +fn split_thin(entry: CStr<'static, Thin>) -> Entry { + let start = entry.as_ptr().cast::(); + let mut len = 0usize; + + for byte in entry.bytes() { + match byte { + b'=' => { + // SAFETY: the scan established the name prefix. + let name: &'static [u8] = unsafe { slice::from_raw_parts(start, len) }; + // SAFETY: `=` precedes the NUL, so the value is a C string. + let value = unsafe { CStr::::from_ptr(start.add(len).add(1).cast()) }; + return (BStr::new(name), Some(value)); + } + _ => { + // A valid C string cannot be `usize::MAX` bytes long. + len += 1; + } + } + } + + // SAFETY: `Bytes` stopped at the NUL after this prefix. + let name: &'static [u8] = unsafe { slice::from_raw_parts(start, len) }; + (BStr::new(name), None) +} + +/// Returns direct thin C-string views of the macOS process arguments. +/// +/// This snapshots the pointer currently exposed by `_NSGetArgv`; it does not +/// count the strings or allocate. +/// +/// # Safety +/// +/// Until the iterator and every view yielded from it are discarded, the +/// caller must ensure that the argument pointer array and strings remain +/// mapped, readable, and immutable and that no new image is executed. +#[must_use] +pub unsafe fn args() -> ThinArgs { + // SAFETY: `_NSGetArgv` returns the address of the live argument pointer, + // and the caller accepts responsibility for keeping it stable. + let first = unsafe { read_array(libc::_NSGetArgv()) }; + ThinArgs { inner: PointerIter { current: first } } +} + +/// Returns direct thin C-string views of the macOS process environment. +/// +/// This snapshots the pointer currently exposed by `_NSGetEnviron`; it does +/// not count the strings or allocate. +/// +/// # Safety +/// +/// Until the iterator and every view yielded from it are discarded, the +/// caller must ensure that no thread mutates the environment or executes a new +/// image. Such operations may replace the pointer array or its strings. +#[must_use] +pub unsafe fn envs() -> ThinEnvs { + // SAFETY: `_NSGetEnviron` returns the address of the live environment + // pointer, and the caller accepts responsibility for keeping it stable. + let first = unsafe { read_array(libc::_NSGetEnviron()) }; + ThinEnvs { inner: PointerIter { current: first } } +} + +const unsafe fn read_array(location: *mut *mut *mut c_char) -> *const *const c_char { + // SAFETY: upheld by the direct iterator constructors' contracts and the + // guarantees of the Apple accessor functions. + unsafe { location.read() }.cast_const().cast() +} + +#[cfg(test)] +mod tests { + use bstr::ByteSlice as _; + + use super::*; + + #[test] + fn thin_entries_distinguish_missing_and_empty_values() { + // SAFETY: both literals are NUL-terminated and live for the views. + let missing = unsafe { CStr::::from_ptr(c"INVALID".as_ptr()) }; + // SAFETY: as above. + let empty = unsafe { CStr::::from_ptr(c"EMPTY=".as_ptr()) }; + + let (name, value) = split_thin(missing); + assert_eq!(name.as_bytes(), b"INVALID"); + assert!(value.is_none()); + + let (name, value) = split_thin(empty); + assert_eq!(name.as_bytes(), b"EMPTY"); + assert_eq!(value.unwrap().count().as_bytes(), b""); + } + + #[test] + fn thin_iterators_contain_argv_zero_and_path() { + // SAFETY: this test does not mutate the argument or environment arrays + // while their iterators or borrowed entries are live. + let argv_zero = unsafe { args() }.next().unwrap().count(); + assert_eq!(argv_zero.as_bytes(), std::env::args_os().next().unwrap().as_encoded_bytes()); + + // SAFETY: as above. + let path = unsafe { envs() } + .find(|(name, _)| name.as_bytes() == b"PATH") + .unwrap() + .1 + .unwrap() + .count(); + assert_eq!(path.as_bytes(), std::env::var_os("PATH").unwrap().as_encoded_bytes()); + } +} diff --git a/crates/sigsafe/src/env/mod.rs b/crates/sigsafe/src/env/mod.rs new file mode 100644 index 000000000..bd82e19ca --- /dev/null +++ b/crates/sigsafe/src/env/mod.rs @@ -0,0 +1,30 @@ +//! Allocation-free process argument and environment iteration. +//! +//! [`current`] snapshots both collections at once. Linux obtains their string +//! bounds from one read of `/proc/self/stat`; macOS snapshots the pointer +//! arrays exposed by `_NSGetArgv` and `_NSGetEnviron`. +//! +//! macOS additionally exposes `args` and `envs` as direct thin C-string +//! views. Neither platform interface can attach a lifetime to its +//! process-global storage, so construction is unsafe and yielded views +//! deliberately carry a `'static` lifetime. + +use bstr::BStr; + +use crate::{CStr, Fat}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod mac; + +#[cfg(target_os = "linux")] +pub use linux::{Current, FatArgs, FatEnvs, current}; +#[cfg(target_os = "macos")] +pub use mac::{Current, FatArgs, FatEnvs, ThinArgs, ThinEnvs, args, current, envs}; + +/// One process environment entry, split at its first `=` byte. +/// +/// Entries without `=` retain their complete bytes as the name and have no +/// value. An entry ending in `=` has a present, empty C string value. +pub type Entry = (&'static BStr, Option>); diff --git a/crates/sigsafe/src/lib.rs b/crates/sigsafe/src/lib.rs index 550a4ec65..b46d2f6c9 100644 --- a/crates/sigsafe/src/lib.rs +++ b/crates/sigsafe/src/lib.rs @@ -15,6 +15,7 @@ #![cfg_attr(not(test), no_std)] mod c_str; +pub mod env; pub mod fs; pub mod mm; pub mod param; @@ -23,7 +24,7 @@ pub use c_str::{Bytes, CStr, Fat, Thin}; pub use rustix::{ fd::{AsRawFd, BorrowedFd}, fs::CWD, - io::{Errno, Result}, + io::{Errno, Errno as Error, Result}, }; // Compile-time proof that rustix uses its raw-syscall backend (`linux_raw`)