diff --git a/crates/engine/src/local_copy/executor/file/guard.rs b/crates/engine/src/local_copy/executor/file/guard.rs index 26dc79e55..0d845bfe4 100644 --- a/crates/engine/src/local_copy/executor/file/guard.rs +++ b/crates/engine/src/local_copy/executor/file/guard.rs @@ -307,6 +307,9 @@ impl DestinationWriteGuard { /// kernel lacks the opcode. /// /// upstream: `util1.c:robust_rename()` - retry up to 4 times on `ETXTBSY`. + // TODO: SEC-1.j receiver wiring - DirSandbox not in scope, defer pending + // `DestinationWriteGuard` carrying a sandbox handle threaded through the + // local-copy executor and its callers; cross-crate API change. fn commit_named_temp_file(&self, temp_path: PathBuf) -> Result<(), LocalCopyError> { let mut tries = 4u32; loop { diff --git a/crates/transfer/src/disk_commit/process.rs b/crates/transfer/src/disk_commit/process.rs index 47e3714b0..72c81a8e6 100644 --- a/crates/transfer/src/disk_commit/process.rs +++ b/crates/transfer/src/disk_commit/process.rs @@ -366,6 +366,10 @@ fn commit_file( /// /// This mirrors the pattern used in `engine::local_copy::executor::file::guard` /// for local-copy temp-file commits. +// TODO: SEC-1.j receiver wiring - DirSandbox not in scope, defer pending +// `DiskCommitConfig` carrying an `Arc` across the cross-thread +// message boundary; `BackupConfig::make_backup` needs the same plumbing +// for the backup rename hardening. fn rename_with_io_uring_fallback(old_path: &Path, new_path: &Path) -> io::Result<()> { if let Some(result) = fast_io::try_rename_via_io_uring(old_path, new_path) { return result; @@ -476,6 +480,9 @@ fn make_backup(file_path: &Path, config: &BackupConfig) -> io::Result<()> { } } + // TODO: SEC-1.j receiver wiring - DirSandbox not in scope, defer pending + // `BackupConfig` carrying an `Arc` across the cross-thread + // message boundary into the disk-commit thread. fs::rename(file_path, &backup_path)?; // upstream: backup.c:216-217 - DEBUG_GTE(BACKUP, 1) on the RENAME success // branch of link_or_rename. disk_commit always uses rename here. diff --git a/crates/transfer/src/receiver/transfer/pipeline.rs b/crates/transfer/src/receiver/transfer/pipeline.rs index 90ea9684a..f9ca26193 100644 --- a/crates/transfer/src/receiver/transfer/pipeline.rs +++ b/crates/transfer/src/receiver/transfer/pipeline.rs @@ -298,6 +298,8 @@ impl ReceiverContext { config: &request_config, #[cfg(unix)] sandbox: setup.sandbox.as_deref(), + #[cfg(unix)] + dest_dir: Some(setup.dest_dir.as_path()), }; let xattr_list = self.resolve_xattr_list(file_entry); diff --git a/crates/transfer/src/transfer_ops/mod.rs b/crates/transfer/src/transfer_ops/mod.rs index b94c5e55c..c6ca77e52 100644 --- a/crates/transfer/src/transfer_ops/mod.rs +++ b/crates/transfer/src/transfer_ops/mod.rs @@ -181,10 +181,19 @@ pub struct ResponseContext<'a> { /// SEC-1.f-j cutover sites can resolve relative names against a /// sandboxed dirfd via `*at` syscalls instead of re-walking paths /// through the kernel. `None` when the receiver could not open the - /// destination root (e.g. it does not exist yet). This PR (SEC-1.e) - /// only wires the carrier; no syscalls are migrated yet. + /// destination root (e.g. it does not exist yet). #[cfg(unix)] pub sandbox: Option<&'a fast_io::DirSandbox>, + /// Destination tree root anchor for the SEC-1.j leaf-rename detector. + /// + /// `process_file_response` uses this together with `sandbox` to route + /// the temp -> final rename through `renameat(dirfd, leaf, dirfd, + /// leaf)` when both the temp and final names are single-component + /// leaves beneath this root, so a TOCTOU symlink swap on either leaf + /// cannot redirect the commit. Multi-component / cross-tree cases + /// keep the path-based fallback. `None` when no anchor is available. + #[cfg(unix)] + pub dest_dir: Option<&'a std::path::Path>, } /// Reads and validates the echoed NDX and sum_head from the sender response. diff --git a/crates/transfer/src/transfer_ops/response.rs b/crates/transfer/src/transfer_ops/response.rs index 84c76ef16..ee2425af0 100644 --- a/crates/transfer/src/transfer_ops/response.rs +++ b/crates/transfer/src/transfer_ops/response.rs @@ -281,10 +281,53 @@ pub fn process_file_response( // On Linux 5.11+ with io_uring, submits IORING_OP_RENAMEAT instead of // synchronous rename(2). Falls back to std::fs::rename on all other // platforms or when the kernel lacks the opcode. + // + // SEC-1.j: when the sandbox is plumbed and both temp leaf + final + // leaf are single components beneath `dest_dir`, route through + // `renameat(dirfd, leaf, dirfd, leaf)` so a TOCTOU swap on either + // leaf cannot redirect the commit. The io_uring fast path is + // preserved by trying it first; the sandbox routing is the SEC-1.j + // hardening for the synchronous fallback. Multi-component / + // cross-tree cases keep the path-based `std::fs::rename`. if let Some(result) = fast_io::try_rename_via_io_uring(cleanup_guard.path(), &file_path) { result?; } else { - fs::rename(cleanup_guard.path(), &file_path)?; + #[cfg(unix)] + { + let temp_path = cleanup_guard.path(); + let (sandbox_dest_dir, temp_rel, final_rel) = match ctx.dest_dir { + Some(dest_dir) => { + let temp_rel = temp_path + .strip_prefix(dest_dir) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|_| temp_path.to_path_buf()); + let final_rel = file_path + .strip_prefix(dest_dir) + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|_| file_path.clone()); + (dest_dir, temp_rel, final_rel) + } + None => ( + std::path::Path::new(""), + temp_path.to_path_buf(), + file_path.clone(), + ), + }; + fast_io::renameat_via_sandbox_or_fallback( + ctx.sandbox, + sandbox_dest_dir, + &temp_rel, + temp_path, + sandbox_dest_dir, + &final_rel, + &file_path, + true, + )?; + } + #[cfg(not(unix))] + { + fs::rename(cleanup_guard.path(), &file_path)?; + } } } else if ctx.config.inplace { // Inplace: truncate to final size.