diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de359c01..362a6b916 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 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. - **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..a2fe2f73a 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 @@ -36,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/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..ec3e6876b --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs @@ -0,0 +1,270 @@ +//! 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. + +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::windows_redirect::convert_in_place; +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, + ] + ); +} + +#[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 9f70a0dc4..d2e7aac32 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -1,28 +1,83 @@ -//! 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)?; + // 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) } +/// Windows creates no quarantine directory, so there is nothing to finish. pub(in crate::pinned) fn remove_leftover( _dir: &PinnedDir, _quarantine: &OsStr, @@ -31,3 +86,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..1037077ae 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1883,3 +1883,52 @@ 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). + +**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 +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 +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).