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).