Skip to content

Commit 6703658

Browse files
wan9chicodex
andcommitted
feat(sigsafe): add filesystem wrappers
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 961414c commit 6703658

13 files changed

Lines changed: 697 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/sigsafe/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ publish = false
88
doctest = false
99

1010
[target.'cfg(unix)'.dependencies]
11-
rustix = { workspace = true }
11+
rustix = { workspace = true, features = ["fs"] }
12+
13+
[target.'cfg(target_os = "macos")'.dependencies]
14+
libc = { workspace = true }
1215

1316
# On Linux the page size is probed from the kernel directly (see param.rs);
1417
# rustix's `param` is only needed where sysconf is the platform interface.
@@ -19,6 +22,7 @@ rustix = { workspace = true, features = ["param"] }
1922
# item to reference; `runtime` is the module that has one.
2023
[target.'cfg(target_os = "linux")'.dependencies]
2124
rustix = { workspace = true, features = ["runtime"] }
25+
syscalls = { workspace = true }
2226

2327
# Cross-validates the page-size probe against rustix's auxv-based answer.
2428
[target.'cfg(target_os = "linux")'.dev-dependencies]

crates/sigsafe/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ rustix can be built with a libc backend instead of raw syscalls, and anything in
2929
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.
3030

3131
- `mm` — anonymous memory mappings: `mmap_anonymous`, `munmap`.
32+
- `fs` — caller-buffer filesystem operations: `getcwd`, plus macOS `fcntl_getpath`.
3233
- `param``page_size`.
3334

3435
Allocation without malloc lives in [`sigsafe_alloc`](../sigsafe_alloc).

crates/sigsafe/src/c_str.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
use core::{
2+
cmp::Ordering,
3+
ffi::c_char,
4+
fmt,
5+
hash::{Hash, Hasher},
6+
marker::PhantomData,
7+
num::NonZeroUsize,
8+
ptr::NonNull,
9+
slice,
10+
};
11+
12+
/// Marks a [`CStr`] whose length is not known.
13+
#[derive(Clone, Copy)]
14+
pub struct Thin {
15+
_private: (),
16+
}
17+
18+
/// Marks a [`CStr`] that retains its length, including the terminating NUL.
19+
#[derive(Clone, Copy)]
20+
pub struct Fat {
21+
len_with_nul: NonZeroUsize,
22+
}
23+
24+
/// A borrowed NUL-terminated string.
25+
///
26+
/// The representation parameter records whether the string's length is
27+
/// available without scanning:
28+
///
29+
/// - [`CStr<'_, Thin>`] stores only the string pointer.
30+
/// - [`CStr<'_, Fat>`] also stores the length including the terminating NUL.
31+
///
32+
/// This is a Rust representation, not an FFI type. Pass [`CStr::as_ptr`] to C
33+
/// functions rather than placing `CStr` in an `extern` function signature.
34+
#[derive(Clone, Copy)]
35+
pub struct CStr<'a, R> {
36+
ptr: NonNull<c_char>,
37+
repr: R,
38+
lifetime: PhantomData<&'a c_char>,
39+
}
40+
41+
impl<R> CStr<'_, R> {
42+
/// Returns a pointer to the first byte of this C string.
43+
#[must_use]
44+
pub const fn as_ptr(&self) -> *const c_char {
45+
self.ptr.as_ptr()
46+
}
47+
48+
#[doc(hidden)]
49+
#[must_use]
50+
pub fn into_repr(self) -> R {
51+
self.repr
52+
}
53+
}
54+
55+
impl Fat {
56+
/// Returns the represented number of bytes, including the terminating NUL.
57+
#[doc(hidden)]
58+
#[must_use]
59+
pub const fn len_with_nul(self) -> usize {
60+
self.len_with_nul.get()
61+
}
62+
}
63+
64+
impl<'a> CStr<'a, Thin> {
65+
/// Creates a thin C string view without finding its length.
66+
///
67+
/// # Safety
68+
///
69+
/// `ptr` must be non-null and point to an immutable NUL-terminated string
70+
/// that remains valid for the lifetime of the returned view.
71+
#[must_use]
72+
pub const unsafe fn from_ptr(ptr: *const c_char) -> Self {
73+
Self {
74+
// SAFETY: upheld by the caller.
75+
ptr: unsafe { NonNull::new_unchecked(ptr.cast_mut()) },
76+
repr: Thin { _private: () },
77+
lifetime: PhantomData,
78+
}
79+
}
80+
81+
/// Counts the bytes before the terminating NUL and returns a
82+
/// length-retaining view.
83+
#[must_use]
84+
pub fn count(self) -> CStr<'a, Fat> {
85+
let mut count = 0;
86+
// Volatile reads prevent this loop from being replaced with a libc
87+
// `strlen` call, which is forbidden by `sigsafe` on Linux.
88+
// SAFETY: `CStr<Thin>` points to a NUL-terminated string.
89+
while unsafe { self.as_ptr().add(count).read_volatile() } != 0 {
90+
count += 1;
91+
}
92+
let len_with_nul = count + 1;
93+
94+
CStr {
95+
ptr: self.ptr,
96+
repr: Fat {
97+
// SAFETY: every C string contains at least its terminating
98+
// NUL.
99+
len_with_nul: unsafe { NonZeroUsize::new_unchecked(len_with_nul) },
100+
},
101+
lifetime: PhantomData,
102+
}
103+
}
104+
}
105+
106+
impl<'a> CStr<'a, Fat> {
107+
/// Creates a length-retaining C string from a byte slice without
108+
/// validation.
109+
///
110+
/// # Safety
111+
///
112+
/// `bytes` must end with exactly one NUL byte and contain no other NUL
113+
/// bytes.
114+
#[must_use]
115+
pub const unsafe fn from_bytes_with_nul_unchecked(bytes: &'a [u8]) -> Self {
116+
Self {
117+
// SAFETY: a valid C string is nonempty, so its pointer is non-null.
118+
ptr: unsafe { NonNull::new_unchecked(bytes.as_ptr().cast::<c_char>().cast_mut()) },
119+
repr: Fat {
120+
// SAFETY: a valid C string contains at least its terminating
121+
// NUL.
122+
len_with_nul: unsafe { NonZeroUsize::new_unchecked(bytes.len()) },
123+
},
124+
lifetime: PhantomData,
125+
}
126+
}
127+
128+
/// Returns this string as a thin C string without scanning it.
129+
#[must_use]
130+
pub const fn into_thin(self) -> CStr<'a, Thin> {
131+
CStr { ptr: self.ptr, repr: Thin { _private: () }, lifetime: PhantomData }
132+
}
133+
134+
/// Returns the string's bytes without the terminating NUL.
135+
#[must_use]
136+
pub const fn as_bytes(&self) -> &'a [u8] {
137+
// SAFETY: this view carries the exact initialized C string length.
138+
unsafe { slice::from_raw_parts(self.ptr.as_ptr().cast(), self.count_bytes()) }
139+
}
140+
141+
/// Returns the string's bytes, including the terminating NUL.
142+
#[must_use]
143+
pub const fn as_bytes_with_nul(&self) -> &'a [u8] {
144+
// SAFETY: this view carries the exact initialized C string length.
145+
unsafe { slice::from_raw_parts(self.ptr.as_ptr().cast(), self.len_with_nul()) }
146+
}
147+
148+
/// Returns the number of bytes including the terminating NUL.
149+
#[must_use]
150+
pub const fn len_with_nul(&self) -> usize {
151+
self.repr.len_with_nul()
152+
}
153+
154+
/// Returns the number of bytes before the terminating NUL.
155+
#[must_use]
156+
pub const fn count_bytes(&self) -> usize {
157+
self.len_with_nul() - 1
158+
}
159+
160+
/// Returns whether this C string is empty.
161+
#[must_use]
162+
pub const fn is_empty(&self) -> bool {
163+
self.count_bytes() == 0
164+
}
165+
166+
pub(crate) const fn as_core_c_str(&self) -> &core::ffi::CStr {
167+
// SAFETY: `CStr<Fat>` maintains the same byte invariant.
168+
unsafe { core::ffi::CStr::from_bytes_with_nul_unchecked(self.as_bytes_with_nul()) }
169+
}
170+
}
171+
172+
impl fmt::Debug for CStr<'_, Thin> {
173+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174+
formatter.debug_tuple("CStr").field(&self.as_ptr()).finish()
175+
}
176+
}
177+
178+
impl fmt::Debug for CStr<'_, Fat> {
179+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180+
self.as_core_c_str().fmt(formatter)
181+
}
182+
}
183+
184+
impl PartialEq for CStr<'_, Fat> {
185+
fn eq(&self, other: &Self) -> bool {
186+
self.as_bytes() == other.as_bytes()
187+
}
188+
}
189+
190+
impl Eq for CStr<'_, Fat> {}
191+
192+
impl PartialOrd for CStr<'_, Fat> {
193+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
194+
Some(self.cmp(other))
195+
}
196+
}
197+
198+
impl Ord for CStr<'_, Fat> {
199+
fn cmp(&self, other: &Self) -> Ordering {
200+
self.as_bytes().cmp(other.as_bytes())
201+
}
202+
}
203+
204+
impl Hash for CStr<'_, Fat> {
205+
fn hash<H: Hasher>(&self, state: &mut H) {
206+
self.as_bytes().hash(state);
207+
}
208+
}
209+
210+
#[cfg(test)]
211+
mod tests {
212+
use core::mem::size_of;
213+
214+
use super::{CStr, Fat, Thin};
215+
216+
#[test]
217+
fn representations_retain_the_expected_metadata() {
218+
// SAFETY: the byte string contains one trailing NUL.
219+
let fat = unsafe { CStr::<Fat>::from_bytes_with_nul_unchecked(b"abc\0") };
220+
let thin = fat.into_thin();
221+
let counted = thin.count();
222+
223+
assert_eq!(size_of::<CStr<'_, Thin>>(), size_of::<*const u8>());
224+
assert_eq!(size_of::<CStr<'_, Fat>>(), size_of::<(*const u8, usize)>());
225+
assert_eq!(thin.as_ptr(), fat.as_ptr());
226+
assert_eq!(fat.len_with_nul(), 4);
227+
assert_eq!(fat.count_bytes(), 3);
228+
assert_eq!(fat.as_bytes(), b"abc");
229+
assert_eq!(fat.as_bytes_with_nul(), b"abc\0");
230+
assert_eq!(counted, fat);
231+
}
232+
}

crates/sigsafe/src/fs/linux.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use core::{mem::MaybeUninit, slice};
2+
3+
use rustix::{
4+
fd::{FromRawFd as _, OwnedFd},
5+
fs::{Mode, OFlags},
6+
};
7+
8+
use crate::{CStr, Errno, Fat, Result};
9+
10+
// Linux UAPI `PATH_MAX`.
11+
pub(super) const PATH_MAX: usize = 4096;
12+
const AT_FDCWD: usize = (-100_isize).cast_unsigned();
13+
14+
#[expect(clippy::needless_pass_by_value, reason = "CStr is a borrowed value type")]
15+
pub(super) fn open<R>(path: CStr<'_, R>, flags: OFlags, mode: Mode) -> Result<OwnedFd> {
16+
// SAFETY: `path` points to a NUL-terminated string for the duration of the
17+
// call, and the remaining arguments are passed through unchanged.
18+
let fd = unsafe {
19+
syscalls::syscall4(
20+
syscalls::Sysno::openat,
21+
AT_FDCWD,
22+
path.as_ptr().addr(),
23+
flags.bits() as usize,
24+
mode.bits() as usize,
25+
)
26+
}
27+
.map_err(|errno| Errno::from_raw_os_error(errno.into_raw()))?;
28+
29+
// SAFETY: a successful `openat` syscall returns a nonnegative raw file
30+
// descriptor represented by `c_int`.
31+
let fd = unsafe { i32::try_from(fd).unwrap_unchecked() };
32+
// SAFETY: ownership of the newly opened descriptor transfers to `OwnedFd`.
33+
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
34+
}
35+
36+
pub(super) fn getcwd(buf: &mut [MaybeUninit<u8>]) -> Result<CStr<'_, Fat>> {
37+
// rustix has this allocation-free backend operation internally, but
38+
// exposes only an allocating `getcwd` that owns and may grow a `Vec`.
39+
// Issue the same raw syscall here until rustix exposes its
40+
// caller-buffer operation publicly.
41+
// SAFETY: `buf` is writable for exactly `buf.len()` bytes. The Linux
42+
// `getcwd` syscall writes no more than that and returns the number of
43+
// initialized bytes, including its terminating NUL.
44+
let initialized =
45+
unsafe { syscalls::syscall2(syscalls::Sysno::getcwd, buf.as_mut_ptr().addr(), buf.len()) }
46+
.map_err(|errno| Errno::from_raw_os_error(errno.into_raw()))?;
47+
48+
// SAFETY: the syscall initialized this prefix through its terminating NUL.
49+
let bytes = unsafe { slice::from_raw_parts(buf.as_ptr().cast(), initialized) };
50+
// SAFETY: the syscall returned one NUL-terminated pathname.
51+
Ok(unsafe { CStr::from_bytes_with_nul_unchecked(bytes) })
52+
}

0 commit comments

Comments
 (0)