From bdf2011ff98befe0f300439865d83c588b92ca6d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:03:23 +0200 Subject: [PATCH] feat(core): remove on Windows through the authenticated handle (#445) The object is opened through the handle-relative lookup with delete access and read-only sharing, so no writer can change the bytes between the authentication and the delete. Only then the POSIX delete disposition is set on that same handle. There is no FILE_FLAG_DELETE_ON_CLOSE and no fallback to the classic disposition; a read-only file is refused. The disposition is the only new unsafe call, with one fixed structure whose layout is pinned by a test. A counter-proof shows what write sharing would have let through. The cases that every platform shares now run on Windows too. --- CHANGELOG.md | 2 + .../src/pinned/tests/mod.rs | 2 + .../src/pinned/tests/remove.rs | 37 +-- .../src/pinned/tests/remove_windows.rs | 212 ++++++++++++++++++ .../src/pinned/windows/removal.rs | 108 +++++++-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 31 +++ 6 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa4cb234..b30cd22d5 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. No new package. Issue #359. PR #1071. + - **R-15 Gate 4:** removal of a directory on Unix, the recovery of leftovers and the crash cases, `test-support` only (slice H1a-2, part 3 of 4). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterwards; `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 lost permission and a foreign object left by a refused put-back are covered. No new `unsafe`, no new package. Issue #359. PR #1070. - **R-15 Gate 4:** removal of a file on Unix, `test-support` only (slice H1a-2, part 2 of 4). `PinnedDir::remove_authenticated_file` moves the entry into a quarantine directory with `RENAME_NOREPLACE`, identifies the open object there and only then unlinks it; anything else is put back, a failure after the move rolls back or reports `Stranded`, and the parent is synced. The one window no Unix primitive closes (a same-user actor in the quarantine directory after the last identification) is forced in a test and reported as `Disturbed`, detected after the fact and not prevented. The rustix `process` feature is added for `geteuid`; no new package, no new `unsafe`. Directories, leftovers and Windows follow. Issue #359. PR #1069. diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index 950f4ed6c..1acddc1a7 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -10,6 +10,8 @@ mod read; mod remove; #[cfg(all(feature = "test-support", unix))] mod remove_unix; +#[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 75039ba93..2929334fc 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"); @@ -180,26 +168,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..3f30374e1 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs @@ -0,0 +1,212 @@ +//! 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_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; +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()]) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs index 9f70a0dc4..9ddf54e70 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -1,28 +1,74 @@ -//! 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. 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_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 +77,39 @@ 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) => match error.raw_os_error().map(|code| code as u32) { + Some(ERROR_DIR_NOT_EMPTY) => Ok(RemovalOutcome::NotEmpty), + // A file system or Windows version without POSIX delete semantics: nothing is deleted. + Some(ERROR_INVALID_PARAMETER | ERROR_NOT_SUPPORTED) => Err(PinnedError::Unsupported), + _ => Err(io_error(error)), + }, + } +} + +/// `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 a04939052..06840a45e 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1807,3 +1807,34 @@ directory by a refused put-back is never removed by it; an entry stranded by a q permission is finished after the permission is back and the file authenticates again. **Not claimed.** Windows creates no quarantine directory (part 4); the Disturbed window is bounded and detected, not closed. + +#### 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 +Unix window does not exist here, but the claim holds for a file system that enforces the sharing rule (NTFS).