Skip to content

Commit afbcf50

Browse files
wan9chiclaude
andcommitted
feat(worldline): follow atomic write-then-rename to the final filename
Tools (editors, build systems, Claude Code) write files atomically: write a `<name>.tmp.<rand>` then `rename()` it over the target. worldline observed only the temp file's write, so its timeline showed `report.txt.tmp.4f3a…` instead of `report.txt`. fspy now surfaces a `Renamed` file event — a new `CallbackKind`/`FileEventKind` carrying source + destination — by interposing the rename family in the preload backend (`rename`/`renameat`/`renameat2` on Linux; `rename`/`renameat`/ `renamex_np`/`renameatx_np` on macOS). Rename carries no descriptor, so a placeholder fd keeps the supervisor's receive path unchanged. worldline relabels any captured write of the rename source to display its destination (content stays stored under the original key, so reconstruction is unchanged). The seccomp (musl) and Windows backends don't surface renames yet, so atomic writes still show the temp name there. Verified end-to-end (a re-signed hardened claude under worldline now shows `report.txt`, not the temp). Adds Unix regression tests at the fspy callback layer and the worldline layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e471d1e commit afbcf50

14 files changed

Lines changed: 394 additions & 29 deletions

File tree

crates/fspy/src/callback/mod.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ use std::{fs::File, mem::ManuallyDrop, path::Path, sync::Arc};
2020

2121
use fspy_shared::ipc::AccessMode;
2222

23-
/// Whether a [`FileEvent`] fires right after an open or right before a close.
23+
/// What a [`FileEvent`] reports: an open, a close, or a rename.
2424
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2525
pub enum FileEventKind {
2626
/// The file has just been opened; the fd/handle is valid.
2727
Opened,
2828
/// The file is about to be closed; the fd/handle is still valid.
2929
Closing,
30+
/// A file was just renamed. [`FileEvent::path`] is the source and
31+
/// [`FileEvent::to_path`] the destination. No usable descriptor is provided
32+
/// ([`FileEvent::fd`] is a placeholder).
33+
Renamed,
3034
}
3135

3236
/// The path carried by a [`FileEvent`].
@@ -132,9 +136,13 @@ pub struct FileEvent<'a> {
132136
/// seccomp backend, where the target's descriptor is assigned by the kernel
133137
/// after the open callback runs).
134138
pub raw_fd: i64,
135-
/// Path of the file. Always present for [`FileEventKind::Opened`].
139+
/// Path of the file. Always present for [`FileEventKind::Opened`]; for
140+
/// [`FileEventKind::Renamed`] it is the rename source.
136141
pub path: FileEventPath<'a>,
137-
/// A file descriptor / handle usable inside the supervisor process.
142+
/// Rename destination. `Some` only for [`FileEventKind::Renamed`].
143+
pub to_path: Option<&'a Path>,
144+
/// A file descriptor / handle usable inside the supervisor process. Not
145+
/// meaningful for [`FileEventKind::Renamed`] (rename carries no descriptor).
138146
pub fd: BorrowedFile<'a>,
139147
}
140148

crates/fspy/src/callback/unix.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,19 +108,21 @@ struct ParsedRequest {
108108
mode: fspy_shared::ipc::AccessMode,
109109
raw_fd: i64,
110110
path: Option<PathBuf>,
111+
to_path: Option<PathBuf>,
111112
}
112113

113114
impl ParsedRequest {
114115
fn decode(body: &[u8]) -> io::Result<Self> {
115116
let request: CallbackRequest<'_> = wincode::deserialize_exact(body)
116117
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
117-
let kind = if request.kind == CallbackKind::CLOSING {
118-
FileEventKind::Closing
119-
} else {
120-
FileEventKind::Opened
118+
let kind = match request.kind {
119+
CallbackKind::CLOSING => FileEventKind::Closing,
120+
CallbackKind::RENAMED => FileEventKind::Renamed,
121+
_ => FileEventKind::Opened,
121122
};
122123
let path = request.path.map(|native| PathBuf::from(native.as_os_str()));
123-
Ok(Self { kind, pid: request.pid, mode: request.mode, raw_fd: request.fd, path })
124+
let to_path = request.to_path.map(|native| PathBuf::from(native.as_os_str()));
125+
Ok(Self { kind, pid: request.pid, mode: request.mode, raw_fd: request.fd, path, to_path })
124126
}
125127
}
126128

@@ -129,7 +131,7 @@ impl ParsedRequest {
129131
async fn run_callback(callback: Arc<FileCallbackFn>, request: ParsedRequest, owned_fd: OwnedFd) {
130132
let result = tokio::task::spawn_blocking(move || {
131133
let path = match request.kind {
132-
FileEventKind::Opened => {
134+
FileEventKind::Opened | FileEventKind::Renamed => {
133135
FileEventPath::Open(request.path.as_deref().unwrap_or_else(|| Path::new("")))
134136
}
135137
FileEventKind::Closing => FileEventPath::Close(request.path.as_deref()),
@@ -140,6 +142,7 @@ async fn run_callback(callback: Arc<FileCallbackFn>, request: ParsedRequest, own
140142
mode: request.mode,
141143
raw_fd: request.raw_fd,
142144
path,
145+
to_path: request.to_path.as_deref(),
143146
fd: BorrowedFile::new(owned_fd.as_fd()),
144147
};
145148
(*callback)(event);

crates/fspy/src/callback/windows.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,19 +178,22 @@ struct ParsedRequest {
178178
mode: fspy_shared::ipc::AccessMode,
179179
raw_fd: i64,
180180
path: Option<PathBuf>,
181+
to_path: Option<PathBuf>,
181182
}
182183

183184
impl ParsedRequest {
184185
fn decode(body: &[u8]) -> io::Result<Self> {
185186
let request: CallbackRequest<'_> = wincode::deserialize_exact(body)
186187
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
187-
let kind = if request.kind == CallbackKind::CLOSING {
188-
FileEventKind::Closing
189-
} else {
190-
FileEventKind::Opened
188+
let kind = match request.kind {
189+
CallbackKind::CLOSING => FileEventKind::Closing,
190+
CallbackKind::RENAMED => FileEventKind::Renamed,
191+
_ => FileEventKind::Opened,
191192
};
192193
let path = request.path.map(|native| PathBuf::from(native.to_cow_os_str().into_owned()));
193-
Ok(Self { kind, pid: request.pid, mode: request.mode, raw_fd: request.fd, path })
194+
let to_path =
195+
request.to_path.map(|native| PathBuf::from(native.to_cow_os_str().into_owned()));
196+
Ok(Self { kind, pid: request.pid, mode: request.mode, raw_fd: request.fd, path, to_path })
194197
}
195198
}
196199

@@ -199,7 +202,7 @@ impl ParsedRequest {
199202
async fn run_callback(callback: Arc<FileCallbackFn>, request: ParsedRequest, handle: OwnedHandle) {
200203
let result = tokio::task::spawn_blocking(move || {
201204
let path = match request.kind {
202-
FileEventKind::Opened => {
205+
FileEventKind::Opened | FileEventKind::Renamed => {
203206
FileEventPath::Open(request.path.as_deref().unwrap_or_else(|| Path::new("")))
204207
}
205208
FileEventKind::Closing => FileEventPath::Close(request.path.as_deref()),
@@ -210,6 +213,7 @@ async fn run_callback(callback: Arc<FileCallbackFn>, request: ParsedRequest, han
210213
mode: request.mode,
211214
raw_fd: request.raw_fd,
212215
path,
216+
to_path: request.to_path.as_deref(),
213217
fd: BorrowedFile::new(handle.as_handle()),
214218
};
215219
(*callback)(event);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ pub(super) fn invoke_callback(
170170
// callback runs, so open/close cannot be paired by fd here.
171171
raw_fd: -1,
172172
path,
173+
// The seccomp backend reports opens and closes, not renames.
174+
to_path: None,
173175
fd: BorrowedFile::new(fd.as_fd()),
174176
};
175177
let invoke: &FileCallbackFn = &*callback.callback;

crates/fspy/tests/file_callback.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,45 @@ async fn close_callback_fires_for_node_write() -> anyhow::Result<()> {
292292
Ok(())
293293
}
294294

295+
/// A successful `rename` fires a `Renamed` event carrying both the source and
296+
/// the destination path. (Unix preload backend; the `rename` interception lives
297+
/// there — the seccomp and Windows backends don't surface renames.)
298+
#[cfg(all(unix, not(target_env = "musl")))]
299+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
300+
async fn rename_event_reports_source_and_destination() -> anyhow::Result<()> {
301+
let dir = tempfile::tempdir()?;
302+
let dir_path = std::fs::canonicalize(dir.path())?;
303+
std::fs::write(dir_path.join("from.txt"), b"x")?;
304+
305+
let renames: Arc<Mutex<Vec<(PathBuf, PathBuf)>>> = Arc::new(Mutex::new(Vec::new()));
306+
let callback = {
307+
let (dir_path, renames) = (dir_path.clone(), Arc::clone(&renames));
308+
move |event: FileEvent<'_>| {
309+
if event.kind == FileEventKind::Renamed
310+
&& let (Some(from), Some(to)) = (event.path.get(), event.to_path)
311+
&& from.starts_with(&dir_path)
312+
{
313+
renames.lock().unwrap().push((from.to_path_buf(), to.to_path_buf()));
314+
}
315+
}
316+
};
317+
318+
let cmd = command_for_fn!(dir_path.to_str().unwrap().to_owned(), |dir: String| {
319+
let dir = std::path::Path::new(&dir);
320+
std::fs::rename(dir.join("from.txt"), dir.join("to.txt")).unwrap();
321+
});
322+
let child = spawn_with_callback(cmd, AccessMode::WRITE, callback).await?;
323+
let termination = child.wait_handle.await?;
324+
assert!(termination.status.success());
325+
326+
let renames = renames.lock().unwrap().clone();
327+
assert!(
328+
renames.iter().any(|(from, to)| from.ends_with("from.txt") && to.ends_with("to.txt")),
329+
"expected a rename from.txt -> to.txt, got {renames:?}"
330+
);
331+
Ok(())
332+
}
333+
295334
/// The access-mode mask filters which events reach the callback.
296335
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
297336
async fn mask_filters_events() -> anyhow::Result<()> {

crates/fspy_preload_unix/src/client/callback.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ use std::{
99
cell::Cell,
1010
ffi::OsStr,
1111
io::{self, Read as _, Write as _},
12-
os::unix::{ffi::OsStrExt as _, net::UnixStream},
12+
os::{
13+
fd::AsRawFd as _,
14+
unix::{ffi::OsStrExt as _, net::UnixStream},
15+
},
1316
path::PathBuf,
1417
};
1518

@@ -109,23 +112,59 @@ impl CallbackChannel {
109112
let Some(_guard) = ReentryGuard::enter() else {
110113
return;
111114
};
112-
if let Err(err) = self.round_trip_inner(kind, fd, mode, path) {
115+
if let Err(err) = self.send(kind, fd, mode, path, None) {
113116
eprintln!("fspy: open/close callback round-trip failed: {err}");
114117
}
115118
}
116119

117-
fn round_trip_inner(
120+
/// Run a blocking callback round-trip for a successful `rename` (`from` ->
121+
/// `to`). Fires under the `WRITE` mask; carries no descriptor.
122+
#[expect(
123+
clippy::print_stderr,
124+
reason = "preload library intentionally uses stderr for error reporting"
125+
)]
126+
pub fn round_trip_rename(&self, from: &BStr, to: &BStr) {
127+
if !AccessMode::WRITE.intersects(self.mask) {
128+
return;
129+
}
130+
if is_ignored_path(from) || is_ignored_path(to) {
131+
return;
132+
}
133+
let Some(_guard) = ReentryGuard::enter() else {
134+
return;
135+
};
136+
if let Err(err) =
137+
self.send(CallbackKind::RENAMED, -1, AccessMode::WRITE, Some(from), Some(to))
138+
{
139+
eprintln!("fspy: rename callback round-trip failed: {err}");
140+
}
141+
}
142+
143+
/// Connect, pass a descriptor, send the request, and await the ack. For
144+
/// events with no descriptor (`fd < 0`, e.g. rename) the socket's own fd is
145+
/// passed as a placeholder so the supervisor's receive path is unchanged.
146+
fn send(
118147
&self,
119148
kind: CallbackKind,
120149
fd: c_int,
121150
mode: AccessMode,
122151
path: Option<&BStr>,
152+
to_path: Option<&BStr>,
123153
) -> io::Result<()> {
124154
let native_path: Option<&NativePath> =
125155
path.map(|path| <&NativePath>::from(OsStr::from_bytes(path)));
156+
let native_to: Option<&NativePath> =
157+
to_path.map(|path| <&NativePath>::from(OsStr::from_bytes(path)));
126158
// SAFETY: `getpid` is always safe to call.
127159
let pid = u32::try_from(unsafe { libc::getpid() }).unwrap_or(0);
128-
let request = CallbackRequest { kind, mode, pid, fd: i64::from(fd), path: native_path };
160+
let request = CallbackRequest {
161+
kind,
162+
mode,
163+
pid,
164+
fd: i64::from(fd),
165+
path: native_path,
166+
to_path: native_to,
167+
};
129168

130169
let serialized_size = CallbackRequest::serialized_size(&request)
131170
.map_err(|err| io::Error::other(err.to_string()))?;
@@ -138,7 +177,8 @@ impl CallbackChannel {
138177
// A fresh connection per event: it is dropped (and closed) while the
139178
// reentrancy guard is still held, so its own `close` does not recurse.
140179
let mut socket = UnixStream::connect(&self.socket_path)?;
141-
socket.send_fd(fd)?;
180+
let passed_fd = if fd >= 0 { fd } else { socket.as_raw_fd() };
181+
socket.send_fd(passed_fd)?;
142182
socket.write_all(&len.to_le_bytes())?;
143183
socket.write_all(&body)?;
144184
let mut ack = [0u8; 1];

crates/fspy_preload_unix/src/client/mod.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ impl Client {
162162
};
163163
}
164164

165+
/// Run the blocking rename callback round-trip, resolving both endpoints to
166+
/// absolute paths first.
167+
unsafe fn run_rename_callback(&self, from: impl ToAbsolutePath, to: impl ToAbsolutePath) {
168+
let Some(channel) = &self.callback else {
169+
return;
170+
};
171+
// SAFETY: `from`/`to` only read the caller's path arguments / dir fds.
172+
let _ = unsafe {
173+
from.to_absolute_path(|from_abs| {
174+
let Some(from_abs) = from_abs else {
175+
return Ok(());
176+
};
177+
to.to_absolute_path(|to_abs| {
178+
if let Some(to_abs) = to_abs {
179+
channel.round_trip_rename(from_abs, to_abs);
180+
}
181+
Ok(())
182+
})
183+
})
184+
};
185+
}
186+
165187
/// Run the blocking pre-close callback round-trip for a still-valid fd.
166188
fn run_close_callback(&self, fd: c_int) {
167189
let Some(channel) = &self.callback else {
@@ -235,6 +257,19 @@ pub unsafe fn handle_open_callback(fd: c_int, path: impl ToAbsolutePath, mode: i
235257
unsafe { client.run_open_callback(fd, path, mode) };
236258
}
237259

260+
/// Run the rename callback, if one is registered. Called after a successful
261+
/// `rename`/`renameat`/… so the destination already holds the file.
262+
pub unsafe fn handle_rename(from: impl ToAbsolutePath, to: impl ToAbsolutePath) {
263+
let Some(client) = global_client() else {
264+
return;
265+
};
266+
if client.callback.is_none() || callback::is_reentrant() {
267+
return;
268+
}
269+
// SAFETY: `from`/`to` carry valid path pointers / dir fds from the caller.
270+
unsafe { client.run_rename_callback(from, to) };
271+
}
272+
238273
/// Run the pre-close blocking callback, if one is registered. Called before
239274
/// the real `close`/`fclose`, so `fd` is still valid.
240275
pub fn handle_close(fd: c_int) {

crates/fspy_preload_unix/src/interceptions/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod access;
22
mod close;
33
mod dirent;
44
mod open;
5+
mod rename;
56
mod spawn;
67
mod stat;
78

0 commit comments

Comments
 (0)