From f84ddab7344545a530ae2402e991a58c80f62371 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:59:02 +0200 Subject: [PATCH 1/5] feat(core): remove on Windows through the authenticated handle (#445) Fresh re-cut of #1071 on the main that carries the Unix hardening and the directory removal, not a retarget: that PR was stacked on the history of the squash-merged #1069 and #1070. Its one own change (bdf2011f) is re-applied here with its tests, and the two findings that were open on it are dealt with. test-support only, no caller. One new unsafe call (the disposition, one fixed structure, its layout pinned by a test), no new package. - The object is opened through the handle-relative lookup of every child, with delete access and a share mode of read only, authenticated on that handle and then given the POSIX delete disposition on the same handle: no FILE_FLAG_DELETE_ON_CLOSE, no fallback to the classic disposition, a read-only file is refused. A directory is removed the same way and a directory with entries refuses (NotEmpty). The removal is reported as Removed(NotConfirmed): Windows offers no directory sync. A leftover removal is Unsupported, because Windows creates no quarantine directory. - The cases of remove.rs now run on Windows too. The directory case there expects the removal on Linux and on Windows and Unsupported on any other Unix system, which the Unix re-cut introduced and which would otherwise have failed the hosted Windows run. Findings carried from #1071: - Codex P2: ERROR_INVALID_FUNCTION, which an older Windows or a driver gives when it does not implement the information class, was not mapped and became a plain I/O error. Every code that means "no POSIX delete" now maps to Unsupported through one function, known_refusal, that a test pins (including that an unrelated code stays an error). - Sourcery: a writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the read-only sharing rule, so it can still change the authenticated bytes before the disposition. No sharing mode closes this; it is stated in the module documentation, the evidence document and the changelog as a same-user residual, not claimed closed, and not tested because a hosted run cannot be made to hold such a mapping reliably. Checked here only by compiling for x86_64-pc-windows-msvc (fmt, clippy -D warnings); the hosted Windows run is the evidence. The PR number in the changelog entry is set when this PR is opened. Issue #359. --- CHANGELOG.md | 2 + .../src/pinned/removal.rs | 11 +- .../src/pinned/tests/mod.rs | 2 + .../src/pinned/tests/remove.rs | 44 +--- .../src/pinned/tests/remove_windows.rs | 240 ++++++++++++++++++ .../src/pinned/windows/removal.rs | 125 ++++++++- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 36 +++ 7 files changed, 403 insertions(+), 57 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de359c01..c5694aeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed). No new package. Issue #359. PR #1071. + - **R-15 Gate 4:** removal of a directory on Linux, the recovery of leftovers and the crash cases on Unix, `test-support` only (slice H1a-2, part 3). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterward; macOS reports no usable signal for a removed directory that is still open (hosted probe: link count and `F_GETPATH` unchanged), so there an empty directory answers `Unsupported` before anything is moved; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a blocked put-back and a foreign object left by a refused put-back are covered; the held directory is synced for what the unlink changed in it, like any removal; a non-empty directory swapped in the last window is `Disturbed`, not `NotEmpty`. The recovery also removes an empty directory left by a crashed directory removal (Linux; it holds nothing, so it is not authenticated), and syncs the parent when it removes the emptied quarantine directory, whatever the removal came to. No new `unsafe`, no new package. Issue #359. PR #1074. - **R-15 Gate 4:** hardening of the Unix file removal against a same-user actor in the quarantine directory, `test-support` only (slice H1a-2, part 2b of 5). The parent is synced after a rollback, a stranded entry, a put-back and a vanished name, and the quarantine directory an entry moved through is synced too (a sync of it that fails is an error after a put-back, a kept entry or an entry an actor removed again, and makes a removal `NotConfirmed`, never another outcome); the quarantine directory, also when its setup fails, is removed by its name only after a check that the name still leads to the directory that was created (the check narrows the race and does not close it, since `rmdir` takes a name: a lost race removes at most an empty directory); an entry, a foreign one included, is reported as retained only if the recorded name still leads to its directory and the entry in it was looked at and is the one that was seen, otherwise `Disturbed` (a look at a name, so it detects after the fact and does not prevent; a foreign entry is not opened, so nothing holds its identity unique and a reused identity is not noticed); a quarantine directory that cannot be opened is refused. Tests force each case, including the quarantine renamed away and the put-back blocked. No new `unsafe`, no new package. Issue #359. PR #1073. diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 241d7a0ca..75e2e9bb7 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -4,11 +4,12 @@ //! **Availability.** Everything in this module exists only with the test-only `test-support` feature, //! has no caller, and is not part of any production build. Reclamation of a real store is not admitted. //! -//! **Status of this slice.** The API and its isolation, and on Unix the removal of files and of the -//! leftovers of a crash, and on Linux the removal of directories. A call on Windows, and the removal of -//! a directory on any other system, answers [`PinnedError::Unsupported`] (see -//! [`PinnedDir::remove_empty_dir`]), so nothing is removed there and nothing claims a safety it does not -//! have yet. +//! **Status of this slice.** The API and its isolation; on Unix the removal of files and of the leftovers +//! of a crash, on Linux the removal of directories, and on Windows the removal of files and directories +//! through the authenticated handle. The removal of a directory on any other Unix system, and the +//! leftover recovery on Windows (which creates no quarantine directory), answer +//! [`PinnedError::Unsupported`] (see [`PinnedDir::remove_empty_dir`]), so nothing is removed there and +//! nothing claims a safety it does not have. //! //! **The two designs the platform slices do not use.** Removing "the entry that was checked" with //! `unlinkat(parent, name)` after a check on the name is exposed to a swap of the last component: POSIX diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index f77c34734..983683cf3 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -18,6 +18,8 @@ mod remove_unix_cleanup; mod remove_unix_dir; #[cfg(all(feature = "test-support", unix))] mod remove_unix_sync; +#[cfg(all(feature = "test-support", windows))] +mod remove_windows; #[cfg(windows)] mod windows_redirect; diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs index 539376f0a..e54d16d46 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs @@ -1,19 +1,14 @@ -//! Removal on every platform that implements it: only the authenticated object goes, and a refusal -//! changes nothing. Windows has no implementation in this part yet and keeps the refusal test. +//! Removal on every platform: only the authenticated object goes, and a refusal changes nothing. use std::fs; -#[cfg(unix)] use super::link_to_dir; use super::Scratch; -#[cfg(unix)] use crate::durable::DirectoryDurability; -#[cfg(unix)] use crate::pinned::PinnedFile; use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome}; /// Accepts a file whose whole content is `expected`. -#[cfg(unix)] pub(super) fn holds( expected: &'static [u8], ) -> impl FnOnce(&mut PinnedFile) -> Result { @@ -21,7 +16,6 @@ pub(super) fn holds( } /// What a completed removal reports on this platform. -#[cfg(unix)] pub(super) fn removed() -> Result { let durability = if cfg!(unix) { DirectoryDurability::Confirmed @@ -31,7 +25,6 @@ pub(super) fn removed() -> Result { Ok(RemovalOutcome::Removed(durability)) } -#[cfg(unix)] pub(super) fn names(pinned: &PinnedDir) -> Vec { let mut listed: Vec = pinned .entries(32) @@ -43,7 +36,6 @@ pub(super) fn names(pinned: &PinnedDir) -> Vec { listed } -#[cfg(unix)] #[test] fn an_authenticated_file_is_removed_and_nothing_else_is() { let scratch = Scratch::new("remove"); @@ -58,7 +50,6 @@ fn an_authenticated_file_is_removed_and_nothing_else_is() { ); } -#[cfg(unix)] #[test] fn a_refused_authentication_a_failing_one_and_a_wrong_identity_remove_nothing() { let scratch = Scratch::new("refuse"); @@ -89,7 +80,6 @@ fn a_refused_authentication_a_failing_one_and_a_wrong_identity_remove_nothing() ); } -#[cfg(unix)] #[test] fn a_link_a_directory_and_a_missing_name_are_not_removed_as_files() { let scratch = Scratch::new("not-files"); @@ -119,7 +109,6 @@ fn a_link_a_directory_and_a_missing_name_are_not_removed_as_files() { ); } -#[cfg(unix)] #[test] fn removing_a_hard_linked_name_leaves_the_other_names() { let scratch = Scratch::new("hard-link"); @@ -147,7 +136,6 @@ fn removing_a_hard_linked_name_leaves_the_other_names() { ); } -#[cfg(unix)] #[test] fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() { let scratch = Scratch::new("dirs"); @@ -159,9 +147,10 @@ fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() { fs::create_dir(aside.join("target")).unwrap(); link_to_dir(&aside.join("target"), &root.join("link")); let pinned = PinnedDir::open_root(&root).unwrap(); - // Only Linux can tell afterwards that the open directory was the one removed; elsewhere the removal - // of an empty directory is refused before anything is moved, and the other cases are the same. - let (empty_removal, left) = if cfg!(target_os = "linux") { + // Linux (a link count) and Windows (the delete is the disposition of the open handle) can tell that + // the directory they removed is the one they opened; on any other Unix system the removal of an empty + // directory is refused before anything is moved, and the other cases are the same. + let (empty_removal, left) = if cfg!(any(target_os = "linux", windows)) { (removed(), vec!["filled".to_string(), "link".to_string()]) } else { ( @@ -194,26 +183,3 @@ fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() { ) ); } - -#[cfg(windows)] -#[test] -fn every_removal_refuses_until_windows_implements_it_and_removes_nothing() { - let scratch = Scratch::new("remove-stub"); - let root = scratch.root(); - fs::write(root.join("a"), b"alpha").unwrap(); - fs::create_dir(root.join("d")).unwrap(); - let pinned = PinnedDir::open_root(&root).unwrap(); - let refusals: [Result; 3] = [ - pinned.remove_authenticated_file("a", |_| Ok(true)), - pinned.remove_empty_dir("d"), - pinned.remove_leftover_file(".h1q-00000000000000000000000000000000", |_| Ok(true)), - ]; - assert_eq!( - ( - refusals, - fs::read(root.join("a")).unwrap(), - root.join("d").is_dir() - ), - ([Err(PinnedError::Unsupported); 3], b"alpha".to_vec(), true) - ); -} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs new file mode 100644 index 000000000..aced483d9 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs @@ -0,0 +1,240 @@ +//! Windows removal: while the authenticated handle is held the name cannot be rebound and the bytes +//! cannot be written, a competing handle makes the removal fail closed, and the delete is a disposition +//! of the authenticated handle. The counter-proof shows what a share mode that allowed writing would +//! have let through. + +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::Read; +use std::mem::{align_of, size_of}; + +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_DIR_NOT_EMPTY, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, + ERROR_NOT_SUPPORTED, ERROR_SHARING_VIOLATION, +}; +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_DISPOSITION_INFO_EX, FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, +}; + +use super::remove::{holds, names, removed}; +use super::Scratch; +use crate::pinned::windows::{nt, removal::known_refusal}; +use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage}; + +/// Whether opening `path` for writing is refused because another handle holds the file. +fn writer_is_refused(path: &std::path::Path) -> bool { + OpenOptions::new() + .write(true) + .open(path) + .is_err_and(|error| error.raw_os_error() == Some(ERROR_SHARING_VIOLATION as i32)) +} + +#[test] +fn while_the_authenticated_handle_is_held_the_name_cannot_be_rebound() { + let scratch = Scratch::new("win-rebind"); + let (root, aside) = (scratch.root(), scratch.aside()); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::write(aside.join("other"), b"other").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let mut attempts = None; + let outcome = pinned.remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if stage == RemovalStage::Checked { + attempts = Some(( + fs::rename(root.join("a"), aside.join("original")).is_err(), + fs::remove_file(root.join("a")).is_err(), + fs::rename(aside.join("other"), root.join("a")).is_err(), + )); + } + Ok(()) + }); + assert_eq!( + ( + outcome, + attempts, + root.join("a").exists(), + fs::read(aside.join("other")).unwrap() + ), + ( + removed(), + Some((true, true, true)), + false, + b"other".to_vec() + ) + ); +} + +#[test] +fn nobody_can_write_to_the_file_between_the_authentication_and_the_delete() { + let scratch = Scratch::new("win-no-writer"); + let root = scratch.root(); + let path = root.join("a"); + fs::write(&path, b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let (mut during_authentication, mut at_the_check) = (None, None); + let outcome = pinned.remove_authenticated_file_observed( + "a", + |file| { + during_authentication = Some(writer_is_refused(&path)); + Ok(file.read_at_most(8)? == b"alpha") + }, + &mut |stage| { + if stage == RemovalStage::Checked { + at_the_check = Some(writer_is_refused(&path)); + } + Ok(()) + }, + ); + assert_eq!( + (outcome, during_authentication, at_the_check, path.exists()), + (removed(), Some(true), Some(true), false) + ); +} + +#[test] +fn counter_proof_a_handle_that_shared_write_would_let_a_writer_change_the_bytes() { + let scratch = Scratch::new("win-shared-write"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let mut held = nt::open_relative( + &pinned.handle, + OsStr::new("a"), + FILE_GENERIC_READ | DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + ) + .unwrap(); + // The write succeeds although the delete handle is held: with this share mode the bytes that were + // authenticated could be replaced before the disposition. + fs::write(root.join("a"), b"changed").unwrap(); + let mut seen = Vec::new(); + held.read_to_end(&mut seen).unwrap(); + assert_eq!(seen, b"changed".to_vec()); +} + +#[test] +fn a_writer_that_already_has_the_file_open_makes_the_removal_busy_and_changes_nothing() { + let scratch = Scratch::new("win-busy"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + // std shares read, write and delete; what matters is that this handle can write. + let writer = OpenOptions::new().write(true).open(root.join("a")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let outcome = pinned.remove_authenticated_file("a", |_| Ok(true)); + drop(writer); + assert_eq!( + (outcome, fs::read(root.join("a")).unwrap()), + (Err(PinnedError::Busy), b"alpha".to_vec()) + ); +} + +#[test] +fn a_reader_that_shares_delete_does_not_stop_the_removal_and_the_name_is_free_at_once() { + let scratch = Scratch::new("win-share-delete"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + let reader = File::open(root.join("a")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let outcome = pinned.remove_authenticated_file("a", holds(b"alpha")); + let name_free = File::create_new(root.join("a")).is_ok(); + drop(reader); + assert_eq!((outcome, name_free), (removed(), true)); +} + +#[test] +fn a_directory_is_removed_only_while_empty_and_its_name_cannot_be_rebound_meanwhile() { + let scratch = Scratch::new("win-dir"); + let (root, aside) = (scratch.root(), scratch.aside()); + fs::create_dir(root.join("d")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let mut rebound = None; + let outcome = pinned.remove_empty_dir_observed("d", &mut |stage| { + if stage == RemovalStage::Checked { + rebound = Some(fs::rename(root.join("d"), aside.join("moved")).is_err()); + } + Ok(()) + }); + assert_eq!( + (outcome, rebound, root.join("d").exists()), + (removed(), Some(true), false) + ); +} + +#[test] +fn a_read_only_file_is_not_deleted_and_the_refusal_is_reported() { + let scratch = Scratch::new("win-readonly"); + let root = scratch.root(); + let path = root.join("a"); + fs::write(&path, b"alpha").unwrap(); + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_readonly(true); + fs::set_permissions(&path, permissions.clone()).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let outcome = pinned.remove_authenticated_file("a", holds(b"alpha")); + // The attribute is cleared with the permissions of a file that is not read-only. + fs::set_permissions(&path, fs::metadata(&root).unwrap().permissions()).unwrap(); + assert_eq!( + (outcome.is_err(), fs::read(&path).unwrap()), + (true, b"alpha".to_vec()) + ); +} + +#[test] +fn a_leftover_removal_is_unsupported_because_windows_creates_no_quarantine() { + let scratch = Scratch::new("win-leftover"); + let pinned = PinnedDir::open_root(&scratch.root()).unwrap(); + assert_eq!( + pinned.remove_leftover_file(".h1q-0", |_| Ok(true)), + Err(PinnedError::Unsupported) + ); +} + +#[test] +fn the_disposition_structure_has_the_layout_the_system_reads() { + assert_eq!( + ( + size_of::(), + align_of::() + ), + (4, 4) + ); +} + +#[test] +fn a_name_is_listed_only_if_nothing_else_was_changed() { + let scratch = Scratch::new("win-names"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::write(root.join("b"), b"beta").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let outcome: Result = + pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + (outcome, names(&pinned)), + (removed(), vec!["b".to_string()]) + ); +} + +#[test] +fn every_code_that_means_no_posix_delete_is_unsupported_and_other_codes_stay_errors() { + // `ERROR_INVALID_FUNCTION` is what an older Windows or a driver answers when it does not implement + // the information class at all; the disposition was refused and nothing was deleted, like the other two. + let unsupported = Some(Err(PinnedError::Unsupported)); + assert_eq!( + [ + known_refusal(Some(ERROR_DIR_NOT_EMPTY)), + known_refusal(Some(ERROR_INVALID_PARAMETER)), + known_refusal(Some(ERROR_NOT_SUPPORTED)), + known_refusal(Some(ERROR_INVALID_FUNCTION)), + known_refusal(Some(ERROR_ACCESS_DENIED)), + known_refusal(None), + ], + [ + Some(Ok(RemovalOutcome::NotEmpty)), + unsupported, + unsupported, + unsupported, + None, + None, + ] + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs index 9f70a0dc4..b4c66fcff 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -1,28 +1,78 @@ -//! Windows removal, test-support only: not implemented in this slice. Every call refuses with -//! `Unsupported`; a successor slice brings the handle-relative delete. See the parent `removal` module -//! for the contract. +//! Windows removal, test-support only: authenticate on the handle, then an explicit disposition on that +//! same handle. See the parent `removal` module for the reasoning. +//! +//! The object is opened through the handle-relative lookup of every child, with delete access and a +//! share mode of **read only**. Sharing write would let another handle change the bytes between the +//! authentication and the disposition; read-only sharing makes a writer that has the file open fail +//! this open (`Busy`) and every later writer fail its own, and no delete sharing keeps the binding of the +//! name to the object in place. That sharing rule is about handles: a writable memory-mapped view that +//! another process created before and keeps after closing its handle is not stopped by it, so such a +//! writer can still change the authenticated bytes (a same-user residual, stated in the evidence document; +//! no sharing mode closes it). The disposition is set only after authentication succeeded; failing +//! authentication, an error or dropping the handle deletes nothing (there is deliberately no +//! `FILE_FLAG_DELETE_ON_CLOSE`, and no fallback to the classic disposition: a Windows or file system +//! without POSIX delete semantics is `Unsupported`). use std::ffi::OsStr; +use std::fs::File; +use std::io; +use std::os::windows::io::AsRawHandle; -use crate::pinned::{Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome}; +use windows_sys::Win32::Foundation::{ + ERROR_DIR_NOT_EMPTY, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED, + HANDLE, +}; +use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfoEx, SetFileInformationByHandle, DELETE, FILE_DISPOSITION_FLAG_DELETE, + FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, FILE_DISPOSITION_INFO_EX, FILE_GENERIC_READ, + FILE_SHARE_READ, FILE_SHARE_WRITE, +}; + +use super::{assert_not_converted, expect_kind, identity_of, io_error, nt}; +use crate::durable::{DirectoryDurability, EntryKind}; +use crate::pinned::{Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage}; pub(in crate::pinned) fn remove_file( - _dir: &PinnedDir, - _name: &OsStr, - _authenticate: impl FnOnce(&mut PinnedFile) -> Result, - _observer: Observer<'_>, + dir: &PinnedDir, + name: &OsStr, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + observer: Observer<'_>, ) -> Result { - Err(PinnedError::Unsupported) + assert_not_converted(&dir.handle)?; + let file = nt::open_relative( + &dir.handle, + name, + FILE_GENERIC_READ | DELETE, + FILE_SHARE_READ, + )?; + let file = expect_kind(file, EntryKind::File)?; + let identity = identity_of(&file)?; + let mut pinned = PinnedFile { file, identity }; + if !authenticate(&mut pinned)? { + return Ok(RemovalOutcome::Refused); + } + observer(RemovalStage::Checked)?; + dispose(&pinned.file) } pub(in crate::pinned) fn remove_dir( - _dir: &PinnedDir, - _name: &OsStr, - _observer: Observer<'_>, + dir: &PinnedDir, + name: &OsStr, + observer: Observer<'_>, ) -> Result { - Err(PinnedError::Unsupported) + assert_not_converted(&dir.handle)?; + let handle = nt::open_relative( + &dir.handle, + name, + FILE_GENERIC_READ | DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + )?; + let handle = expect_kind(handle, EntryKind::Directory)?; + observer(RemovalStage::Checked)?; + dispose(&handle) } +/// Windows creates no quarantine directory, so there is nothing to finish. pub(in crate::pinned) fn remove_leftover( _dir: &PinnedDir, _quarantine: &OsStr, @@ -31,3 +81,52 @@ pub(in crate::pinned) fn remove_leftover( ) -> Result { Err(PinnedError::Unsupported) } + +/// Deletes the object of `file`, which a non-empty directory refuses. +fn dispose(file: &File) -> Result { + match set_posix_delete(file) { + Ok(()) => Ok(RemovalOutcome::Removed(DirectoryDurability::NotConfirmed)), + Err(error) => known_refusal(error.raw_os_error().map(|code| code as u32)) + .unwrap_or_else(|| Err(io_error(error))), + } +} + +/// What a refusal of the disposition means, for the codes the system uses for it; `None` for any other +/// error, which stays an error. A directory with entries is `NotEmpty`. A Windows version or a file system +/// (or driver) without POSIX delete semantics answers with one of three codes, and nothing was deleted: +/// `Unsupported`. `ERROR_INVALID_FUNCTION` is the one an older Windows or a driver gives when it does not +/// implement the information class at all. +pub(in crate::pinned) fn known_refusal( + code: Option, +) -> Option> { + match code? { + ERROR_DIR_NOT_EMPTY => Some(Ok(RemovalOutcome::NotEmpty)), + ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED | ERROR_INVALID_FUNCTION => { + Some(Err(PinnedError::Unsupported)) + } + _ => None, + } +} + +/// `FileDispositionInfoEx` with delete and POSIX semantics, on exactly this handle. +#[allow(unsafe_code)] +fn set_posix_delete(file: &File) -> io::Result<()> { + let info = FILE_DISPOSITION_INFO_EX { + Flags: FILE_DISPOSITION_FLAG_DELETE | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, + }; + // SAFETY: `file` keeps the handle open, with delete access, for the whole call; `info` is a live, + // aligned FILE_DISPOSITION_INFO_EX of exactly the size passed, which is the structure + // FileDispositionInfoEx reads (its layout is pinned by a test), and it outlives this synchronous call. + let success = unsafe { + SetFileInformationByHandle( + file.as_raw_handle() as HANDLE, + FileDispositionInfoEx, + (&info as *const FILE_DISPOSITION_INFO_EX).cast(), + std::mem::size_of::() as u32, + ) + }; + if success == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 37a8a22db..75d936468 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1883,3 +1883,39 @@ stranded by a lost search permission is `Disturbed`, not `Stranded`: a failed lo **Not claimed.** Windows creates no quarantine directory (part 4); the Disturbed window is bounded and detected, not closed. A foreign entry that is neither a file nor an empty directory (a link, a FIFO) stays in its `.h1q-*` directory: the recovery refuses it (`Refused`) and no sweep touches it, so nothing removes it until a person decides. + +#### Part 4: removal on Windows + +Status words: `H1A_2_WINDOWS_REMOVAL=IMPLEMENTED_TEST_SUPPORT_ONLY`, `H1_PROVED=NO`. The object is opened through the handle-relative +lookup of every child (`NtCreateFile` with the pinned directory as `RootDirectory`), with delete access and a share mode of +**read only**. With write sharing another handle could change the bytes between the authentication and the delete; +with read-only sharing a writer that already has the file open makes this open fail (`Busy`), no later writer can open it +while the handle is held, and without delete sharing the name cannot be renamed, deleted or replaced. Only after the caller +authenticated the bytes on that handle, `FileDispositionInfoEx` (delete, POSIX semantics) is set on **that same handle**. +Failing authentication, an error or dropping the handle deletes nothing: there is no `FILE_FLAG_DELETE_ON_CLOSE` and no fallback +to the classic disposition (a Windows or file system without POSIX delete semantics answers `Unsupported`), and a read-only +file is refused, not forced. A directory is removed the same way and only while empty (`NotEmpty` otherwise). There is no +quarantine directory and none of the Unix window: the guarantee is the one the system gives for a held handle. The result +reports `DirectoryDurability::NotConfirmed`, since Windows offers no directory sync through a handle opened for reading. + +**FFI.** The disposition is the only new `unsafe` (`SetFileInformationByHandle` with one fixed `FILE_DISPOSITION_INFO_EX`; +no generic or safe wrapper over another class). Its SAFETY comment states the handle lifetime (the borrowed `File`), the +rights (opened with delete access), the structure and its size (pinned by a test: 4 bytes, 4-aligned) and that it outlives +the synchronous call. + +**Proof (hosted Windows runner; the local environment compiles only).** While the authenticated handle is held the name cannot +be renamed, deleted or replaced; nobody can open the file for writing during the authentication and at the check (the +share-mode proof), with the **counter-proof** that a handle opened with write sharing lets a writer change the bytes; a writer +that already has the file open makes the removal `Busy` and changes nothing; a reader that shares delete does not stop it and +the name is free at once; a directory is removed only while empty and its name cannot be rebound meanwhile; a read-only file is +refused; a leftover removal is `Unsupported`. The neutral cases of `remove.rs` now run on Windows as well (a refused, failing +or wrong-identity authentication removes nothing; links, junctions, directories and missing names are not removed as files; +hard-linked names; a filled directory and a junction are not removed). + +**Not claimed.** A file system without POSIX delete semantics is `Unsupported`, which the hosted runner does not exercise; the +codes that mean it (`ERROR_INVALID_PARAMETER`, `ERROR_NOT_SUPPORTED` and `ERROR_INVALID_FUNCTION`, the last for a Windows or a +driver that does not implement the information class at all) are mapped by one function that a test pins. The Unix window does +not exist here, but the claim holds for a file system that enforces the sharing rule (NTFS), and the sharing rule is about +handles: a writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by +it, so such a writer can still change the authenticated bytes before the disposition (a same-user residual, like the Unix +window; no sharing mode closes it, and it is not tested because a hosted run cannot be made to hold such a mapping reliably). From a57f8ad5a19ae1c287fa3eb2a0248703210cda08 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:43:57 +0200 Subject: [PATCH 2/5] docs(changelog): set the PR number of the Windows removal entry (#445) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5694aeda..1b2b8f796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed). No new package. Issue #359. PR #1071. +- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed). No new package. Issue #359. PR #1075. - **R-15 Gate 4:** removal of a directory on Linux, the recovery of leftovers and the crash cases on Unix, `test-support` only (slice H1a-2, part 3). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterward; macOS reports no usable signal for a removed directory that is still open (hosted probe: link count and `F_GETPATH` unchanged), so there an empty directory answers `Unsupported` before anything is moved; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a blocked put-back and a foreign object left by a refused put-back are covered; the held directory is synced for what the unlink changed in it, like any removal; a non-empty directory swapped in the last window is `Disturbed`, not `NotEmpty`. The recovery also removes an empty directory left by a crashed directory removal (Linux; it holds nothing, so it is not authenticated), and syncs the parent when it removes the emptied quarantine directory, whatever the removal came to. No new `unsafe`, no new package. Issue #359. PR #1074. From 7c43e60606feaa21bb75de048aa49d2511f34339 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:12:52 +0200 Subject: [PATCH 3/5] docs(core): say that the removal binds the object, not its bytes, on Unix and on Windows (#445) CodeAnt asked whether the mapped-writer limit on Windows is acceptable for this slice. Looking at it showed that the Unix removal has the same limit in a wider form and never said so: there is no sharing mode, so a same-user process with a writable descriptor or a writable shared mapping can change the bytes between the authentication and the unlink. Both are now stated in the module documentation, the evidence document and the changelog as parts of the same-user residual, not closed. Documentation only. --- CHANGELOG.md | 2 +- crates/worldscript-secure-storage/src/pinned/removal.rs | 7 +++++++ docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2b8f796..975558f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed). No new package. Issue #359. PR #1075. +- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. - **R-15 Gate 4:** removal of a directory on Linux, the recovery of leftovers and the crash cases on Unix, `test-support` only (slice H1a-2, part 3). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterward; macOS reports no usable signal for a removed directory that is still open (hosted probe: link count and `F_GETPATH` unchanged), so there an empty directory answers `Unsupported` before anything is moved; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a blocked put-back and a foreign object left by a refused put-back are covered; the held directory is synced for what the unlink changed in it, like any removal; a non-empty directory swapped in the last window is `Disturbed`, not `NotEmpty`. The recovery also removes an empty directory left by a crashed directory removal (Linux; it holds nothing, so it is not authenticated), and syncs the parent when it removes the emptied quarantine directory, whatever the removal came to. No new `unsafe`, no new package. Issue #359. PR #1074. diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 75e2e9bb7..a2fe2f73a 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -37,6 +37,13 @@ //! //! A hard link is one name: removing it leaves the other names. [`PinnedFile::link_count`] lets a caller //! refuse a file with more than one. +//! +//! **What is bound is the object, not its bytes.** `authenticate` reads the open file, and the removal +//! then removes that object. On Unix there is no sharing mode, so a process of the same user that has a +//! writable descriptor or a writable shared mapping of the file can change its bytes between the two. On +//! Windows the read-only share mode stops every writer that opens the file, but not a writable +//! memory-mapped view that another process created earlier and keeps after closing its handle. Neither is +//! closed here; both belong to the same-user residual that is decided before any production use. use std::ffi::OsStr; use std::path::Path; diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 75d936468..7cb025436 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1912,6 +1912,12 @@ refused; a leftover removal is `Unsupported`. The neutral cases of `remove.rs` n or wrong-identity authentication removes nothing; links, junctions, directories and missing names are not removed as files; hard-linked names; a filled directory and a junction are not removed). +**What is bound is the object, not its bytes, on every platform.** `authenticate` reads the open file and the removal then removes +that object. On Unix there is no sharing mode, so a process of the same user with a writable descriptor or a writable shared +mapping of the file can change its bytes between the two (this was not stated for the Unix removal before). On Windows the +read-only share mode stops every writer that opens the file, and the counter-proof shows what write sharing would have let +through, but not a mapped writer, below. Neither is closed here. + **Not claimed.** A file system without POSIX delete semantics is `Unsupported`, which the hosted runner does not exercise; the codes that mean it (`ERROR_INVALID_PARAMETER`, `ERROR_NOT_SUPPORTED` and `ERROR_INVALID_FUNCTION`, the last for a Windows or a driver that does not implement the information class at all) are mapped by one function that a test pins. The Unix window does From 140e981a7ab31955fd8ef9ea6b55f7768bc716d0 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:44:08 +0200 Subject: [PATCH 4/5] fix(core): look at a directory again right before its disposition on Windows (#445) Codex P2 on 7c43e606: the share mode of a directory handle cannot stop a writer from converting the empty directory into a reparse point in place (the module already says so for the pinned directory), and the observer, a caller's code, runs between the check and the disposition. The disposition would then have removed the converted entry and reported it as Removed although the API refuses links. The held handle is now checked a second time right before the disposition: a junction made in that window is left, its target is untouched and the removal answers Redirected. This narrows the window and does not close it; a file cannot be converted because its handle shares no write access. Test: the directory is converted into a junction at the Checked stage with the existing convert_in_place helper. It runs on Windows only, so the hosted Windows run is the evidence (checked here by compiling for the Windows target); it is not mutation-checked locally. Documentation, changelog and evidence document say the same. test-support only, no new unsafe, no new package. Issue #359. --- CHANGELOG.md | 2 +- .../src/pinned/tests/remove_windows.rs | 29 +++++++++++++++++++ .../src/pinned/tests/windows_redirect.rs | 2 +- .../src/pinned/windows/removal.rs | 5 ++++ .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 7 +++++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 975558f26..0b8f801fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, and every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`. A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. +- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`, and a directory is looked at again right before the disposition so that one converted into a junction meanwhile is left alone (`Redirected`; narrows the window, does not close it). A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. - **R-15 Gate 4:** removal of a directory on Linux, the recovery of leftovers and the crash cases on Unix, `test-support` only (slice H1a-2, part 3). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterward; macOS reports no usable signal for a removed directory that is still open (hosted probe: link count and `F_GETPATH` unchanged), so there an empty directory answers `Unsupported` before anything is moved; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a blocked put-back and a foreign object left by a refused put-back are covered; the held directory is synced for what the unlink changed in it, like any removal; a non-empty directory swapped in the last window is `Disturbed`, not `NotEmpty`. The recovery also removes an empty directory left by a crashed directory removal (Linux; it holds nothing, so it is not authenticated), and syncs the parent when it removes the emptied quarantine directory, whatever the removal came to. No new `unsafe`, no new package. Issue #359. PR #1074. diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs index aced483d9..850cdc029 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs @@ -17,6 +17,7 @@ use windows_sys::Win32::Storage::FileSystem::{ }; use super::remove::{holds, names, removed}; +use super::windows_redirect::convert_in_place; use super::Scratch; use crate::pinned::windows::{nt, removal::known_refusal}; use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage}; @@ -238,3 +239,31 @@ fn every_code_that_means_no_posix_delete_is_unsupported_and_other_codes_stay_err ] ); } + +#[test] +fn a_directory_converted_into_a_junction_after_the_check_is_not_deleted() { + // Nothing in the sharing rule stops a writer from converting the empty directory into a reparse point in + // place. The caller's code runs between the check and the disposition (the observer), so the held handle + // is looked at again right before it: the junction is left, the target it leads to is untouched, and the + // removal says it was redirected. + let scratch = Scratch::new("win-dir-convert"); + let (root, aside) = (scratch.root(), scratch.aside()); + fs::create_dir(root.join("d")).unwrap(); + fs::create_dir(aside.join("target")).unwrap(); + fs::write(aside.join("target").join("keep"), b"keep").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let outcome = pinned.remove_empty_dir_observed("d", &mut |stage| { + if stage == RemovalStage::Checked { + convert_in_place(&root.join("d"), &aside.join("target")); + } + Ok(()) + }); + assert_eq!( + ( + outcome, + fs::symlink_metadata(root.join("d")).is_ok(), + fs::read(aside.join("target").join("keep")).unwrap() + ), + (Err(PinnedError::Redirected), true, b"keep".to_vec()) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/windows_redirect.rs b/crates/worldscript-secure-storage/src/pinned/tests/windows_redirect.rs index 845d8131c..968053144 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/windows_redirect.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/windows_redirect.rs @@ -49,7 +49,7 @@ fn mount_point_data(target: &Path) -> Vec { /// Turns the empty directory `dir` into a junction to `target` in place: not renamed, not deleted, the /// same object, which a handle that shares writes does not prevent. #[allow(unsafe_code)] -fn convert_in_place(dir: &Path, target: &Path) { +pub(super) fn convert_in_place(dir: &Path, target: &Path) { let data = mount_point_data(target); let writer = OpenOptions::new() .write(true) diff --git a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs index b4c66fcff..d2e7aac32 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -69,6 +69,11 @@ pub(in crate::pinned) fn remove_dir( )?; let handle = expect_kind(handle, EntryKind::Directory)?; observer(RemovalStage::Checked)?; + // Nothing in the sharing rule stops a writer from converting the empty directory into a reparse point + // in place, and the observer (a caller's code) runs in between: the held handle is looked at again right + // before the disposition. That narrows the window and does not close it; a file cannot be converted, + // because its handle shares no write access. + let handle = expect_kind(handle, EntryKind::Directory)?; dispose(&handle) } diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 7cb025436..1037077ae 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1912,6 +1912,13 @@ refused; a leftover removal is `Unsupported`. The neutral cases of `remove.rs` n or wrong-identity authentication removes nothing; links, junctions, directories and missing names are not removed as files; hard-linked names; a filled directory and a junction are not removed). +**A directory is looked at again right before the disposition.** The share mode of a directory handle cannot stop a writer from +converting the empty directory into a reparse point in place (the module says so for the pinned directory too), and the observer +runs between the check and the disposition. The held handle is therefore checked a second time right before the disposition: +a junction made in that window is left, its target is untouched, and the removal answers `Redirected`. This narrows the window +and does not close it, and it is proven by the hosted Windows run (it cannot run on Linux); a file cannot be converted, because +its handle shares no write access. + **What is bound is the object, not its bytes, on every platform.** `authenticate` reads the open file and the removal then removes that object. On Unix there is no sharing mode, so a process of the same user with a writable descriptor or a writable shared mapping of the file can change its bytes between the two (this was not stated for the Unix removal before). On Windows the From d3d97562290ca49e1175ceb87447e125f255899f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:13:22 +0200 Subject: [PATCH 5/5] docs(core): limit the Windows write protection claim to handles (#445) CodeRabbit on 140e981a: the changelog said no writer can change the authenticated bytes while its own entry notes that a writable mapped view that existed before can. The claim is now limited to handles that open the file, and the test header says the same. Wording only. --- CHANGELOG.md | 2 +- .../src/pinned/tests/remove_windows.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8f801fc..362a6b916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no writer can change the authenticated bytes; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`, and a directory is looked at again right before the disposition so that one converted into a junction meanwhile is left alone (`Redirected`; narrows the window, does not close it). A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. +- **R-15 Gate 4:** removal on Windows, `test-support` only (slice H1a-2, part 4 of 4). The object is opened through the handle-relative lookup with delete access and **read-only sharing** (no handle that opens the file can write to it while the removal holds it, with the exception of a writable mapped view that existed before, see below; a writer that already has it open makes the open fail `Busy`), authenticated on that handle, and then given the POSIX delete disposition on the same handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback, a read-only file is refused. One new `unsafe` call (the disposition, one fixed structure, layout pinned by a test); a counter-proof shows what write sharing would have let through. The cases of `remove.rs` now run on Windows too, every code that means "no POSIX delete" (including `ERROR_INVALID_FUNCTION`) is `Unsupported`, and a directory is looked at again right before the disposition so that one converted into a junction meanwhile is left alone (`Redirected`; narrows the window, does not close it). A writable memory-mapped view that another process created earlier and keeps after closing its handle is not stopped by the sharing rule (same-user residual, stated, not closed); on Unix, which has no sharing mode, any same-user writer can change the bytes between the authentication and the removal, which was not stated before: the removal binds the object, not its bytes. No new package. Issue #359. PR #1075. - **R-15 Gate 4:** removal of a directory on Linux, the recovery of leftovers and the crash cases on Unix, `test-support` only (slice H1a-2, part 3). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterward; macOS reports no usable signal for a removed directory that is still open (hosted probe: link count and `F_GETPATH` unchanged), so there an empty directory answers `Unsupported` before anything is moved; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a blocked put-back and a foreign object left by a refused put-back are covered; the held directory is synced for what the unlink changed in it, like any removal; a non-empty directory swapped in the last window is `Disturbed`, not `NotEmpty`. The recovery also removes an empty directory left by a crashed directory removal (Linux; it holds nothing, so it is not authenticated), and syncs the parent when it removes the emptied quarantine directory, whatever the removal came to. No new `unsafe`, no new package. Issue #359. PR #1074. diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs index 850cdc029..ec3e6876b 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs @@ -1,5 +1,6 @@ -//! Windows removal: while the authenticated handle is held the name cannot be rebound and the bytes -//! cannot be written, a competing handle makes the removal fail closed, and the delete is a disposition +//! Windows removal: while the authenticated handle is held the name cannot be rebound and no handle can +//! be opened to write the bytes (a writable mapped view that existed before is not stopped, see the +//! documentation of the removal), a competing handle makes the removal fail closed, and the delete is a disposition //! of the authenticated handle. The counter-proof shows what a share mode that allowed writing would //! have let through.