Skip to content

Commit 5965ecc

Browse files
wan9chicodex
andcommitted
feat(sigsafe): resolve current directory
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent 961414c commit 5965ecc

14 files changed

Lines changed: 464 additions & 16 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/fspy_preload_unix/src/client/convert.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@ use std::{
66
path::PathBuf,
77
};
88

9+
use allocator_api2::{alloc::Allocator, vec::Vec};
910
use bstr::{BStr, ByteSlice};
1011
use fspy_shared::ipc::AccessMode;
1112
use libc::{c_char, c_int};
12-
use nix::unistd::getcwd;
1313

1414
#[cfg(target_os = "linux")]
1515
fn get_fd_path(fd: RawFd) -> nix::Result<Option<PathBuf>> {
16-
if fd == libc::AT_FDCWD {
17-
return Ok(Some(getcwd()?));
18-
}
1916
match nix::fcntl::readlink(CString::new(format!("/proc/self/fd/{fd}")).unwrap().as_c_str()) {
2017
Ok(path) => Ok(Some(path.into())),
2118
Err(nix::Error::EBADF | nix::Error::ENOENT) => Ok(None), // invalid fd or no such file (Most likely a stdio fd)
@@ -25,9 +22,6 @@ fn get_fd_path(fd: RawFd) -> nix::Result<Option<PathBuf>> {
2522

2623
#[cfg(target_os = "macos")]
2724
fn get_fd_path(fd: RawFd) -> nix::Result<Option<PathBuf>> {
28-
if fd == libc::AT_FDCWD {
29-
return Ok(Some(getcwd()?));
30-
}
3125
let mut path = std::path::PathBuf::new();
3226
match nix::fcntl::fcntl(
3327
// SAFETY: fd is a valid file descriptor provided by the caller, and the borrow does not outlive this function call
@@ -40,6 +34,12 @@ fn get_fd_path(fd: RawFd) -> nix::Result<Option<PathBuf>> {
4034
}
4135
}
4236

37+
fn getcwd<A: Allocator>(allocator: A) -> nix::Result<Vec<u8, A>> {
38+
let bytes = sigsafe_alloc::fs::getcwd(allocator)
39+
.map_err(|errno| nix::errno::Errno::from_raw(errno.raw_os_error()))?
40+
.into_bytes();
41+
Ok(bytes)
42+
}
4343
pub trait ToAbsolutePath {
4444
unsafe fn to_absolute_path<R, F: FnOnce(Option<&BStr>) -> nix::Result<R>>(
4545
self,
@@ -53,8 +53,14 @@ impl ToAbsolutePath for Fd {
5353
self,
5454
f: F,
5555
) -> nix::Result<R> {
56+
if self.0 == libc::AT_FDCWD {
57+
let arena = sigsafe_alloc::arena();
58+
let path = getcwd(&arena)?;
59+
return f(Some(path.as_slice().as_bstr()));
60+
}
61+
5662
let path = get_fd_path(self.0)?;
57-
f(path.as_ref().map(|p| p.as_os_str().as_bytes().as_bstr()))
63+
f(path.as_ref().map(|path| path.as_os_str().as_bytes().as_bstr()))
5864
}
5965
}
6066

@@ -70,6 +76,16 @@ impl ToAbsolutePath for PathAt {
7076

7177
if pathname.first().copied() == Some(b'/') {
7278
f(pathname.into())
79+
} else if self.0 == libc::AT_FDCWD {
80+
let arena = sigsafe_alloc::arena();
81+
let mut abs_path = getcwd(&arena)?;
82+
if !pathname.is_empty() {
83+
if !abs_path.ends_with(b"/") {
84+
abs_path.push(b'/');
85+
}
86+
abs_path.extend_from_slice(pathname);
87+
}
88+
f(Some(abs_path.as_slice().as_bstr()))
7389
} else {
7490
let Some(mut abs_path) = get_fd_path(self.0)? else {
7591
return f(None);

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`.
3233
- `param``page_size`.
3334

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

crates/sigsafe/src/c_str.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use core::{ffi::c_char, marker::PhantomData, num::NonZeroUsize, ptr::NonNull, slice};
2+
3+
/// Marks a [`CStr`] whose length is not known.
4+
#[derive(Clone, Copy)]
5+
pub struct Thin {
6+
_private: (),
7+
}
8+
9+
/// Marks a [`CStr`] that retains its length, including the terminating NUL.
10+
#[derive(Clone, Copy)]
11+
pub struct Fat {
12+
len_with_nul: NonZeroUsize,
13+
}
14+
15+
/// A borrowed NUL-terminated string.
16+
///
17+
/// [`CStr<'_, Thin>`] stores only the string pointer, while
18+
/// [`CStr<'_, Fat>`] also stores the length including the terminating NUL.
19+
#[derive(Clone, Copy)]
20+
pub struct CStr<'a, R> {
21+
ptr: NonNull<c_char>,
22+
repr: R,
23+
lifetime: PhantomData<&'a c_char>,
24+
}
25+
26+
impl<R> CStr<'_, R> {
27+
/// Returns a pointer to the first byte of this C string.
28+
#[must_use]
29+
pub const fn as_ptr(&self) -> *const c_char {
30+
self.ptr.as_ptr()
31+
}
32+
33+
#[doc(hidden)]
34+
#[must_use]
35+
pub fn into_repr(self) -> R {
36+
self.repr
37+
}
38+
}
39+
40+
impl Fat {
41+
#[doc(hidden)]
42+
#[must_use]
43+
pub const fn len_with_nul(self) -> usize {
44+
self.len_with_nul.get()
45+
}
46+
}
47+
48+
impl<'a> CStr<'a, Thin> {
49+
/// Creates a thin C string view without finding its length.
50+
///
51+
/// # Safety
52+
///
53+
/// `ptr` must be non-null and point to an immutable NUL-terminated string
54+
/// that remains valid for the lifetime of the returned view.
55+
#[must_use]
56+
pub const unsafe fn from_ptr(ptr: *const c_char) -> Self {
57+
Self {
58+
// SAFETY: upheld by the caller.
59+
ptr: unsafe { NonNull::new_unchecked(ptr.cast_mut()) },
60+
repr: Thin { _private: () },
61+
lifetime: PhantomData,
62+
}
63+
}
64+
65+
/// Counts through the terminating NUL and returns a length-retaining view.
66+
#[must_use]
67+
pub fn count(self) -> CStr<'a, Fat> {
68+
let mut count = 0;
69+
// Volatile reads prevent this loop from being replaced with a libc
70+
// `strlen` call, which is forbidden by `sigsafe` on Linux.
71+
// SAFETY: `CStr<Thin>` points to a NUL-terminated string.
72+
while unsafe { self.as_ptr().add(count).read_volatile() } != 0 {
73+
count += 1;
74+
}
75+
76+
CStr {
77+
ptr: self.ptr,
78+
repr: Fat {
79+
// SAFETY: the count includes at least the terminating NUL.
80+
len_with_nul: unsafe { NonZeroUsize::new_unchecked(count + 1) },
81+
},
82+
lifetime: PhantomData,
83+
}
84+
}
85+
}
86+
87+
impl<'a> CStr<'a, Fat> {
88+
/// Creates a length-retaining C string from bytes without validation.
89+
///
90+
/// # Safety
91+
///
92+
/// `bytes` must end with exactly one NUL byte and contain no other NUL
93+
/// bytes.
94+
#[must_use]
95+
pub const unsafe fn from_bytes_with_nul_unchecked(bytes: &'a [u8]) -> Self {
96+
Self {
97+
// SAFETY: a valid C string is nonempty, so its pointer is non-null.
98+
ptr: unsafe { NonNull::new_unchecked(bytes.as_ptr().cast::<c_char>().cast_mut()) },
99+
repr: Fat {
100+
// SAFETY: a valid C string contains at least its terminating NUL.
101+
len_with_nul: unsafe { NonZeroUsize::new_unchecked(bytes.len()) },
102+
},
103+
lifetime: PhantomData,
104+
}
105+
}
106+
107+
/// Returns the string's bytes, including the terminating NUL.
108+
#[must_use]
109+
pub const fn as_bytes_with_nul(&self) -> &'a [u8] {
110+
// SAFETY: this view carries the exact initialized C string length.
111+
unsafe { slice::from_raw_parts(self.ptr.as_ptr().cast(), self.len_with_nul()) }
112+
}
113+
114+
/// Returns the number of bytes including the terminating NUL.
115+
#[must_use]
116+
pub const fn len_with_nul(&self) -> usize {
117+
self.repr.len_with_nul()
118+
}
119+
120+
#[cfg(target_os = "macos")]
121+
pub(crate) const fn as_core_c_str(&self) -> &core::ffi::CStr {
122+
// SAFETY: `CStr<Fat>` maintains the same byte invariant.
123+
unsafe { core::ffi::CStr::from_bytes_with_nul_unchecked(self.as_bytes_with_nul()) }
124+
}
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use core::mem::size_of;
130+
131+
use super::{CStr, Fat, Thin};
132+
133+
#[test]
134+
fn representations_retain_the_expected_metadata() {
135+
// SAFETY: the input contains one trailing NUL.
136+
let fat = unsafe { CStr::<Fat>::from_bytes_with_nul_unchecked(b"abc\0") };
137+
// SAFETY: the input contains one trailing NUL.
138+
let counted = unsafe { CStr::<Thin>::from_ptr(c"abc".as_ptr()) }.count();
139+
140+
assert_eq!(size_of::<CStr<'_, Thin>>(), size_of::<*const u8>());
141+
assert_eq!(size_of::<CStr<'_, Fat>>(), size_of::<(*const u8, usize)>());
142+
assert_eq!(fat.len_with_nul(), 4);
143+
assert_eq!(fat.as_bytes_with_nul(), b"abc\0");
144+
assert_eq!(counted.as_bytes_with_nul(), fat.as_bytes_with_nul());
145+
}
146+
}

crates/sigsafe/src/fs/linux.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use core::{mem::MaybeUninit, slice};
2+
3+
use crate::{CStr, Errno, Fat, Result};
4+
5+
// Linux UAPI `PATH_MAX`.
6+
pub(super) const PATH_MAX: usize = 4096;
7+
8+
pub(super) fn getcwd(buf: &mut [MaybeUninit<u8>]) -> Result<CStr<'_, Fat>> {
9+
// rustix exposes only an allocating `getcwd`, so use the raw syscall for
10+
// caller-owned storage.
11+
// SAFETY: `buf` is writable for exactly `buf.len()` bytes. The syscall
12+
// writes no more than that and returns the initialized length including
13+
// its terminating NUL.
14+
let initialized =
15+
unsafe { syscalls::syscall2(syscalls::Sysno::getcwd, buf.as_mut_ptr().addr(), buf.len()) }
16+
.map_err(|errno| Errno::from_raw_os_error(errno.into_raw()))?;
17+
18+
// SAFETY: the syscall initialized this prefix through its terminating NUL.
19+
let bytes = unsafe { slice::from_raw_parts(buf.as_ptr().cast(), initialized) };
20+
// SAFETY: the syscall returned one NUL-terminated pathname.
21+
Ok(unsafe { CStr::from_bytes_with_nul_unchecked(bytes) })
22+
}

crates/sigsafe/src/fs/mac.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use core::{mem::MaybeUninit, slice};
2+
3+
use rustix::{
4+
fd::{AsFd as _, AsRawFd as _, BorrowedFd, FromRawFd as _, OwnedFd},
5+
fs::{Mode, OFlags, fstat, stat},
6+
};
7+
8+
use crate::{CStr, Errno, Fat, Result, Thin};
9+
10+
pub(super) const PATH_MAX: usize = libc::PATH_MAX as usize;
11+
12+
#[expect(clippy::needless_pass_by_value, reason = "CStr is a borrowed value type")]
13+
fn open<R>(path: CStr<'_, R>, flags: OFlags, mode: Mode) -> Result<OwnedFd> {
14+
// SAFETY: `path` is NUL-terminated for the call. Variadic arguments use
15+
// their promoted C types.
16+
let fd = unsafe {
17+
libc::open(path.as_ptr(), flags.bits().cast_signed(), libc::c_uint::from(mode.bits()))
18+
};
19+
if fd == -1 {
20+
// SAFETY: libSystem stored this call's error before returning -1.
21+
return Err(Errno::from_raw_os_error(unsafe { *libc::__error() }));
22+
}
23+
24+
// SAFETY: ownership of the newly opened descriptor transfers here.
25+
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
26+
}
27+
28+
fn fcntl_getpath<'buf>(
29+
fd: BorrowedFd<'_>,
30+
buf: &'buf mut [MaybeUninit<u8>; PATH_MAX],
31+
) -> Result<CStr<'buf, Thin>> {
32+
// SAFETY: `fd` remains borrowed and `buf` has the `MAXPATHLEN` storage
33+
// required by `F_GETPATH`.
34+
let result = unsafe {
35+
libc::fcntl(fd.as_raw_fd(), libc::F_GETPATH, buf.as_mut_ptr().cast::<libc::c_char>())
36+
};
37+
if result == -1 {
38+
// SAFETY: libSystem stored this call's error before returning -1.
39+
return Err(Errno::from_raw_os_error(unsafe { *libc::__error() }));
40+
}
41+
42+
// SAFETY: `F_GETPATH` wrote a NUL-terminated pathname into `buf`.
43+
Ok(unsafe { CStr::from_ptr(buf.as_ptr().cast()) })
44+
}
45+
46+
pub(super) fn getcwd(buf: &mut [MaybeUninit<u8>]) -> Result<CStr<'_, Fat>> {
47+
if buf.len() < PATH_MAX {
48+
return getcwd_small(buf);
49+
}
50+
51+
getcwd_full(buf)
52+
}
53+
54+
// Keep the `PATH_MAX` scratch storage in a separate stack frame so the
55+
// large-buffer path does not reserve it. `inline(never)` preserves that
56+
// conditional stack allocation after optimization.
57+
#[inline(never)]
58+
fn getcwd_small(buf: &mut [MaybeUninit<u8>]) -> Result<CStr<'_, Fat>> {
59+
let mut scratch = [MaybeUninit::uninit(); PATH_MAX];
60+
let initialized = getcwd_full(&mut scratch)?.len_with_nul();
61+
if initialized > buf.len() {
62+
return Err(Errno::RANGE);
63+
}
64+
65+
buf[..initialized].copy_from_slice(&scratch[..initialized]);
66+
// SAFETY: `getcwd_full` initialized this copied C string prefix.
67+
let bytes = unsafe { slice::from_raw_parts(buf.as_ptr().cast(), initialized) };
68+
// SAFETY: upheld by the initialized prefix above.
69+
Ok(unsafe { CStr::from_bytes_with_nul_unchecked(bytes) })
70+
}
71+
72+
/// The allocation-free fast path from Apple's [`getcwd`]: obtain the path
73+
/// of an open `.` descriptor, then verify that it still names `.`.
74+
///
75+
/// [`getcwd`]: https://github.com/apple-oss-distributions/Libc/blob/Libc-1752.120.2/gen/FreeBSD/getcwd.c#L62-L138
76+
fn getcwd_full(buf: &mut [MaybeUninit<u8>]) -> Result<CStr<'_, Fat>> {
77+
let Some(buf) = buf.first_chunk_mut::<PATH_MAX>() else {
78+
return Err(Errno::RANGE);
79+
};
80+
81+
// SAFETY: the byte string contains one trailing NUL.
82+
let dot_path = unsafe { CStr::<Fat>::from_bytes_with_nul_unchecked(b".\0") };
83+
let fd = open(dot_path, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())?;
84+
let dot = fstat(&fd)?;
85+
if dot.st_dev == 0 || dot.st_ino == 0 {
86+
return Err(Errno::INVAL);
87+
}
88+
89+
let path = fcntl_getpath(fd.as_fd(), buf)?;
90+
drop(fd);
91+
92+
// `F_GETPATH` returns no length, so `getcwd` counts the result just as
93+
// libSystem's scratch-buffer path does before checking and copying it.
94+
let path = path.count();
95+
96+
let pointed_to = stat(path.as_core_c_str())?;
97+
if dot.st_dev != pointed_to.st_dev || dot.st_ino != pointed_to.st_ino {
98+
return Err(Errno::INVAL);
99+
}
100+
101+
Ok(path)
102+
}

0 commit comments

Comments
 (0)