Skip to content

Commit 1128edc

Browse files
branchseerclaude
andauthored
refactor: introduce opaque NativePath type for PathAccess (#177)
## Summary - Introduce opaque `NativePath` type wrapping `NativeStr` in new `native_path.rs` - On Windows, tracked paths are NT Object Manager paths (`\??` prefix) whose raw data is not meaningful for direct consumption. `NativePath` only exposes `strip_path_prefix`, which normalizes platform differences and extracts a workspace-relative path - Change `PathAccess.path` from `&NativeStr` to `&NativePath` and make `native_str` module `pub(crate)` - Update sender-side callbacks to use `(AccessMode, &Path)` directly instead of `PathAccess` - Refactor consumers (e2e tests, CLI example, oxlint test) to use `strip_path_prefix` API ## Test plan - [x] `cargo check --all-targets` passes - [x] `just lint` passes - [x] `just fmt` passes - [x] `cargo test` passes - [ ] CI (cross-platform lint + tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 995f80c commit 1128edc

File tree

12 files changed

+160
-110
lines changed

12 files changed

+160
-110
lines changed

crates/fspy/examples/cli.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,9 @@ async fn main() -> anyhow::Result<()> {
2929

3030
for acc in termination.path_accesses.iter() {
3131
path_count += 1;
32+
let path_str = format!("{:?}", acc.path);
3233
let mode_str = format!("{:?}", acc.mode);
33-
csv_writer
34-
.write_record(&[
35-
acc.path.to_cow_os_str().to_string_lossy().as_ref().as_bytes(),
36-
mode_str.as_bytes(),
37-
])
38-
.await?;
34+
csv_writer.write_record(&[path_str.as_bytes(), mode_str.as_bytes()]).await?;
3935
}
4036
csv_writer.flush().await?;
4137

crates/fspy/src/unix/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ impl SpyImpl {
9494
&mut exec,
9595
ExecResolveConfig::search_path_enabled(None),
9696
&encoded_payload,
97-
|path_access| {
98-
exec_resolve_accesses.add(path_access);
97+
|mode, path| {
98+
exec_resolve_accesses.add(PathAccess { mode, path: path.into() });
9999
},
100100
)
101101
.map_err(|err| SpawnError::Injection(err.into()))?;

crates/fspy/src/unix/syscall_handler/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use fspy_seccomp_unotify::{
1515
impl_handler,
1616
supervisor::handler::arg::{CStrPtr, Caller, Fd},
1717
};
18-
use fspy_shared::ipc::{AccessMode, NativeStr, PathAccess};
18+
use fspy_shared::ipc::{AccessMode, PathAccess};
1919

2020
use crate::arena::PathAccessArena;
2121

@@ -63,7 +63,7 @@ impl SyscallHandler {
6363
libc::O_WRONLY => AccessMode::WRITE,
6464
_ => AccessMode::READ,
6565
},
66-
path: NativeStr::from_bytes(path.as_os_str().as_bytes()),
66+
path: path.as_os_str().into(),
6767
});
6868
Ok(())
6969
}
@@ -72,7 +72,7 @@ impl SyscallHandler {
7272
let path = fd.get_path(caller)?;
7373
self.arena.add(PathAccess {
7474
mode: AccessMode::READ_DIR,
75-
path: NativeStr::from_bytes(path.as_bytes()),
75+
path: OsStr::from_bytes(path.as_bytes()).into(),
7676
});
7777
Ok(())
7878
}

crates/fspy/tests/oxlint.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ declare const _foo: Foo;
102102
// Run oxlint without --type-aware first
103103
let accesses = track_oxlint(&tmpdir_path, &[""]).await?;
104104
let access_to_types_ts = accesses.iter().find(|access| {
105-
let os_str = access.path.to_cow_os_str();
106-
os_str.as_encoded_bytes().ends_with(b"\\types.ts")
107-
|| os_str.as_encoded_bytes().ends_with(b"/types.ts")
105+
access
106+
.path
107+
.strip_path_prefix(&tmpdir_path, |result| result.is_ok_and(|p| p.ends_with("types.ts")))
108108
});
109109
assert_eq!(access_to_types_ts, None, "oxlint should not read types.ts without --type-aware");
110110

crates/fspy_e2e/src/main.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,18 @@ impl AccessCollector {
3838
}
3939

4040
pub fn add(&mut self, access: PathAccess) {
41-
let path = PathBuf::from(access.path.to_cow_os_str().to_os_string());
42-
if let Ok(relative_path) = path.strip_prefix(&self.dir) {
43-
let relative_path =
44-
relative_path.to_str().expect("relative path should be valid UTF-8").to_owned();
45-
match self.accesses.entry(relative_path) {
46-
Entry::Vacant(vacant) => {
47-
vacant.insert(access.mode);
48-
}
49-
Entry::Occupied(mut occupied) => {
50-
let occupied_mode = occupied.get_mut();
51-
occupied_mode.insert(access.mode);
52-
}
41+
let Some(relative_path) = access.path.strip_path_prefix(&self.dir, |result| {
42+
result.ok().and_then(|p| p.to_str().map(str::to_owned))
43+
}) else {
44+
return;
45+
};
46+
match self.accesses.entry(relative_path) {
47+
Entry::Vacant(vacant) => {
48+
vacant.insert(access.mode);
49+
}
50+
Entry::Occupied(mut occupied) => {
51+
let occupied_mode = occupied.get_mut();
52+
occupied_mode.insert(access.mode);
5353
}
5454
}
5555
}

crates/fspy_preload_unix/src/client/mod.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ pub mod convert;
22
pub mod raw_exec;
33

44
use std::{
5-
ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, sync::OnceLock,
5+
ffi::OsStr, fmt::Debug, num::NonZeroUsize, os::unix::ffi::OsStrExt as _, path::Path,
6+
sync::OnceLock,
67
};
78

89
use bincode::{enc::write::SizeWriter, encode_into_slice, encode_into_writer};
@@ -58,18 +59,19 @@ impl Client {
5859
Self { encoded_payload, ipc_sender }
5960
}
6061

61-
fn send(&self, path_access: PathAccess<'_>) -> anyhow::Result<()> {
62+
fn send(&self, mode: fspy_shared::ipc::AccessMode, path: &Path) -> anyhow::Result<()> {
6263
let Some(ipc_sender) = &self.ipc_sender else {
6364
// ipc channel not available, skip sending
6465
return Ok(());
6566
};
66-
let path = path_access.path.as_os_str().as_bytes();
67-
if path.starts_with(b"/dev/")
67+
let path_bytes = path.as_os_str().as_bytes();
68+
if path_bytes.starts_with(b"/dev/")
6869
|| (cfg!(target_os = "linux")
69-
&& (path.starts_with(b"/proc/") || path.starts_with(b"/sys/")))
70+
&& (path_bytes.starts_with(b"/proc/") || path_bytes.starts_with(b"/sys/")))
7071
{
7172
return Ok(());
7273
}
74+
let path_access = PathAccess { mode, path: path.into() };
7375
let mut size_writer = SizeWriter::default();
7476
encode_into_writer(path_access, &mut size_writer, BINCODE_CONFIG)?;
7577

@@ -93,8 +95,8 @@ impl Client {
9395
) -> nix::Result<R> {
9496
// SAFETY: raw_exec contains valid pointers to C strings and null-terminated arrays, as provided by the caller
9597
let mut exec = unsafe { raw_exec.to_exec() };
96-
let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |path_access| {
97-
self.send(path_access).unwrap();
98+
let pre_exec = handle_exec(&mut exec, config, &self.encoded_payload, |mode, path| {
99+
self.send(mode, path).unwrap();
98100
})?;
99101
RawExec::from_exec(exec, |raw_command| f(raw_command, pre_exec))
100102
}
@@ -112,7 +114,7 @@ impl Client {
112114
let Some(abs_path) = abs_path else {
113115
return Ok(Ok(()));
114116
};
115-
Ok(self.send(PathAccess { mode, path: OsStr::from_bytes(abs_path).into() }))
117+
Ok(self.send(mode, Path::new(OsStr::from_bytes(abs_path))))
116118
})
117119
}??;
118120

crates/fspy_preload_windows/src/windows/detours/nt.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use fspy_shared::ipc::{AccessMode, NativeStr, PathAccess};
1+
use fspy_shared::ipc::{AccessMode, NativePath, PathAccess};
22
use ntapi::ntioapi::{
33
FILE_INFORMATION_CLASS, NtQueryDirectoryFile, NtQueryFullAttributesFile,
44
NtQueryInformationByName, PFILE_BASIC_INFORMATION, PFILE_NETWORK_OPEN_INFORMATION,
@@ -157,7 +157,7 @@ unsafe fn handle_open(access_mode: impl ToAccessMode, path: impl ToAbsolutePath)
157157
// SAFETY: converting access mask to AccessMode via FFI-aware trait
158158
PathAccess {
159159
mode: access_mode.to_access_mode(),
160-
path: NativeStr::from_wide(path),
160+
path: NativePath::from_wide(path),
161161
}
162162
},
163163
|wildcard_pos| {
@@ -168,7 +168,7 @@ unsafe fn handle_open(access_mode: impl ToAccessMode, path: impl ToAbsolutePath)
168168
.unwrap_or(0);
169169
PathAccess {
170170
mode: AccessMode::READ_DIR,
171-
path: NativeStr::from_wide(&path[..slash_pos]),
171+
path: NativePath::from_wide(&path[..slash_pos]),
172172
}
173173
},
174174
);

crates/fspy_shared/src/ipc/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
pub mod channel;
2-
mod native_str;
2+
mod native_path;
3+
pub(crate) mod native_str;
34

45
use std::fmt::Debug;
56

67
use bincode::{BorrowDecode, Encode, config::Configuration};
78
use bitflags::bitflags;
9+
pub use native_path::NativePath;
810
pub use native_str::NativeStr;
911

1012
pub const BINCODE_CONFIG: Configuration = bincode::config::standard();
@@ -35,16 +37,16 @@ impl Debug for AccessMode {
3537
#[derive(Encode, BorrowDecode, Debug, Clone, Copy, PartialEq, Eq)]
3638
pub struct PathAccess<'a> {
3739
pub mode: AccessMode,
38-
pub path: &'a NativeStr,
40+
pub path: &'a NativePath,
3941
// TODO: add follow_symlinks (O_NOFOLLOW)
4042
}
4143

4244
impl<'a> PathAccess<'a> {
43-
pub fn read(path: impl Into<&'a NativeStr>) -> Self {
45+
pub fn read(path: impl Into<&'a NativePath>) -> Self {
4446
Self { mode: AccessMode::READ, path: path.into() }
4547
}
4648

47-
pub fn read_dir(path: impl Into<&'a NativeStr>) -> Self {
49+
pub fn read_dir(path: impl Into<&'a NativePath>) -> Self {
4850
Self { mode: AccessMode::READ_DIR, path: path.into() }
4951
}
5052
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#[cfg(unix)]
2+
use std::os::unix::ffi::OsStrExt as _;
3+
use std::{
4+
ffi::OsStr,
5+
fmt::Debug,
6+
path::{Path, StripPrefixError},
7+
};
8+
9+
use allocator_api2::alloc::Allocator;
10+
use bincode::{BorrowDecode, Encode, de::BorrowDecoder, error::DecodeError};
11+
use bytemuck::TransparentWrapper;
12+
13+
use super::native_str::NativeStr;
14+
15+
/// An opaque path type used in [`super::PathAccess`].
16+
///
17+
/// On Windows, tracked paths are NT Object Manager paths (`\??` prefix),
18+
/// whose raw data is not meaningful for direct consumption. The only way
19+
/// to use the path is through [`strip_path_prefix`](NativePath::strip_path_prefix),
20+
/// which normalizes platform differences and extracts a workspace-relative path.
21+
#[derive(TransparentWrapper, Encode, PartialEq, Eq)]
22+
#[repr(transparent)]
23+
pub struct NativePath {
24+
inner: NativeStr,
25+
}
26+
27+
impl NativePath {
28+
#[cfg(windows)]
29+
#[must_use]
30+
pub fn from_wide(wide: &[u16]) -> &Self {
31+
Self::wrap_ref(NativeStr::from_wide(wide))
32+
}
33+
34+
pub fn clone_in<'new_alloc, A>(&self, alloc: &'new_alloc A) -> &'new_alloc Self
35+
where
36+
&'new_alloc A: Allocator,
37+
{
38+
Self::wrap_ref(self.inner.clone_in(alloc))
39+
}
40+
41+
pub fn strip_path_prefix<P: AsRef<Path>, R, F: FnOnce(Result<&Path, StripPrefixError>) -> R>(
42+
&self,
43+
base: P,
44+
f: F,
45+
) -> R {
46+
/// Strip the `\\?\`, `\\.\`, `\??\` prefix from a Windows path, if present.
47+
/// Does nothing on non-Windows platforms.
48+
///
49+
/// \\?\ and \\.\ are used to enable long paths and access to device paths.
50+
/// \??\ is used in Nt* calls.
51+
/// The resulting path is not necessarily valid or points to the same location,
52+
/// but it's good enough for sanitizing paths in `NativePath::strip_path_prefix`.
53+
#[cfg_attr(
54+
not(windows),
55+
expect(
56+
clippy::missing_const_for_fn,
57+
reason = "uses non-const for loop and strip_prefix on Windows"
58+
)
59+
)]
60+
fn strip_windows_path_prefix(p: &OsStr) -> &OsStr {
61+
#[cfg(windows)]
62+
{
63+
use os_str_bytes::OsStrBytesExt as _;
64+
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
65+
if let Some(stripped) = p.strip_prefix(prefix) {
66+
return stripped;
67+
}
68+
}
69+
p
70+
}
71+
#[cfg(not(windows))]
72+
{
73+
p
74+
}
75+
}
76+
77+
let me = self.inner.to_cow_os_str();
78+
let me = strip_windows_path_prefix(&me);
79+
let base = strip_windows_path_prefix(base.as_ref().as_os_str());
80+
f(Path::new(me).strip_prefix(base))
81+
}
82+
}
83+
84+
impl Debug for NativePath {
85+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86+
<NativeStr as Debug>::fmt(&self.inner, f)
87+
}
88+
}
89+
90+
impl<'a, C> BorrowDecode<'a, C> for &'a NativePath {
91+
fn borrow_decode<D: BorrowDecoder<'a, Context = C>>(
92+
decoder: &mut D,
93+
) -> Result<Self, DecodeError> {
94+
let inner: &'a NativeStr = BorrowDecode::borrow_decode(decoder)?;
95+
Ok(NativePath::wrap_ref(inner))
96+
}
97+
}
98+
99+
#[cfg(unix)]
100+
impl<'a, S: AsRef<OsStr> + ?Sized> From<&'a S> for &'a NativePath {
101+
fn from(value: &'a S) -> Self {
102+
NativePath::wrap_ref(NativeStr::from_bytes(value.as_ref().as_bytes()))
103+
}
104+
}

crates/fspy_shared/src/ipc/native_str.rs

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,7 @@ use std::os::unix::ffi::OsStrExt as _;
66
use std::os::windows::ffi::OsStrExt as _;
77
#[cfg(windows)]
88
use std::os::windows::ffi::OsStringExt as _;
9-
use std::{
10-
borrow::Cow,
11-
ffi::OsStr,
12-
fmt::Debug,
13-
path::{Path, StripPrefixError},
14-
};
9+
use std::{borrow::Cow, ffi::OsStr, fmt::Debug};
1510

1611
use allocator_api2::alloc::Allocator;
1712
use bincode::{
@@ -141,48 +136,6 @@ impl NativeStr {
141136
let data = data.leak::<'new_alloc>();
142137
Self::wrap_ref(data)
143138
}
144-
145-
pub fn strip_path_prefix<P: AsRef<Path>, R, F: FnOnce(Result<&Path, StripPrefixError>) -> R>(
146-
&self,
147-
base: P,
148-
f: F,
149-
) -> R {
150-
/// Strip the `\\?\`, `\\.\`, `\??\` prefix from a Windows path, if present.
151-
/// Does nothing on non-Windows platforms.
152-
///
153-
/// \\?\ and \\.\ are used to enable long paths and access to device paths.
154-
/// \??\ is used in Nt* calls.
155-
/// The resulting path is not necessarily valid or points to the same location,
156-
/// but it's good enough for sanitizing paths in `NativeStr::strip_path_prefix`.
157-
#[cfg_attr(
158-
not(windows),
159-
expect(
160-
clippy::missing_const_for_fn,
161-
reason = "uses non-const for loop and strip_prefix on Windows"
162-
)
163-
)]
164-
fn strip_windows_path_prefix(p: &OsStr) -> &OsStr {
165-
#[cfg(windows)]
166-
{
167-
use os_str_bytes::OsStrBytesExt as _;
168-
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
169-
if let Some(stripped) = p.strip_prefix(prefix) {
170-
return stripped;
171-
}
172-
}
173-
p
174-
}
175-
#[cfg(not(windows))]
176-
{
177-
p
178-
}
179-
}
180-
181-
let me = self.to_cow_os_str();
182-
let me = strip_windows_path_prefix(&me);
183-
let base = strip_windows_path_prefix(base.as_ref().as_os_str());
184-
f(Path::new(me).strip_prefix(base))
185-
}
186139
}
187140

188141
#[cfg(test)]

0 commit comments

Comments
 (0)