From 03ad9acce58dc7e8d2e72635a4cf3b0c19102c26 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:09:38 +0200 Subject: [PATCH 01/13] feat(core): remove only the object that was authenticated, test-support only (#445) The removal primitives of the pinned module exist only with the test-only feature and have no caller. On Unix an object is moved into a quarantine directory with RENAME_NOREPLACE, the open object is identified there and only then unlinked, with the link count checked afterwards; a swap of the last name or a manipulation inside the quarantine directory is moved back and never unlinked. No exclusivity of that directory is assumed: the one window no Unix primitive closes is forced in a test and reported as Disturbed. On Windows the object is opened through the handle-relative lookup with delete access and read-only sharing, so no writer can change the authenticated bytes, and the POSIX delete disposition is set on that same handle. There is no FILE_FLAG_DELETE_ON_CLOSE and no fallback. Leftovers of a crash are finished only after authenticating them again. A scan of the sources and a compile of a dependent with and without the feature keep the primitives out of every other build. --- CHANGELOG.md | 2 + crates/worldscript-secure-storage/Cargo.toml | 5 +- .../src/pinned/mod.rs | 12 +- .../src/pinned/removal.rs | 148 +++++ .../src/pinned/tests/mod.rs | 6 + .../src/pinned/tests/read.rs | 14 + .../src/pinned/tests/remove.rs | 200 ++++++ .../src/pinned/tests/remove_unix.rs | 613 ++++++++++++++++++ .../src/pinned/tests/remove_windows.rs | 212 ++++++ .../src/pinned/unix.rs | 10 +- .../src/pinned/unix/removal.rs | 365 +++++++++++ .../src/pinned/windows.rs | 9 +- .../src/pinned/windows/removal.rs | 115 ++++ .../tests/feature_isolation_compile_test.rs | 174 +++++ .../tests/feature_isolation_removal_test.rs | 194 ++++++ .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 93 +++ 16 files changed, 2167 insertions(+), 5 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/removal.rs create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove.rs create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs create mode 100644 crates/worldscript-secure-storage/src/pinned/unix/removal.rs create mode 100644 crates/worldscript-secure-storage/src/pinned/windows/removal.rs create mode 100644 crates/worldscript-secure-storage/tests/feature_isolation_compile_test.rs create mode 100644 crates/worldscript-secure-storage/tests/feature_isolation_removal_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6346a8f..6854f39d2 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 bound to the authenticated object, `test-support` only (slice H1a-2). `PinnedDir::remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` have no caller and exist only with the test-only feature (proved by a scan of the sources and by compiling a dependent with and without the feature). Unix moves an object into a quarantine directory with `RENAME_NOREPLACE`, identifies the open object there and only then unlinks it, checking the link count afterwards; a swap of the last name or a manipulation inside the quarantine directory is moved back, never unlinked, and the one window no Unix primitive closes is forced in a test and reported as `Disturbed`. Windows opens the object through the handle-relative lookup with delete access and read-only sharing (no writer can change the authenticated bytes), then sets the POSIX delete disposition on that handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback. Leftovers of a crash are finished only after authenticating again; `PinnedDir::sync` is added. One `unsafe` call (the disposition) and the `rustix` `process` feature, no new package. The sweep is not ported and no production reclamation is admitted. Issue #359. PR #1068. + - **R-15 Gate 4:** handle-relative pinned I/O (slice H1a-1). A new parallel module `pinned` opens one directory with no-follow semantics, resolves it once to a link-free path, and reaches everything below it through a directory that is held open (`PinnedDir`, `PinnedFile`): bounded listing, bounded reading from the start of the file, identity and hard-link count; a link, junction or reparse point is refused at every component below the root, and a name must be exactly one ordinary component (on Windows also no stream, wildcard or device name). Unix uses `*at` calls on directory descriptors and checks the root's path without following a link; Windows pins a directory with a handle that does not share delete and opens children (`NtCreateFile` relative to the handle), listings (the handle's directory information) and entry kinds relative to it, so a directory converted into a reparse point in place leads nowhere. It changes nothing in the file system, has no caller and does not change `DurableFs`; the removal primitives follow in H1a-2 behind `test-support`. Local `unsafe` for the relative open, the listing and the final path, and one typed handle-query helper in `root_lock` (an `unsafe trait` with two implementations); the `Wdk_*` features of the `windows-sys` already locked, no new package. Issue #359. PR #1067. - **R-15 Gate 4:** takeover with a trusted clock observation (slice Q1). The mutation-capable takeover no longer takes a timestamp: `JournalTakeoverCommit` carries a `LeaseClock`, which is sealed (it cannot be implemented outside the crate; the one production clock is `SystemLeaseClock`, the UTC wall clock of the operating system), `commit_journal_takeover` reads it once while the root-commit lock is held into an opaque `TrustedLeaseTimeObservation` (no constructor from a number), and `publish_takeover_fenced` takes that observation. An unavailable or unset (zero) clock refuses before a journal or authority byte is written. The predicate now refuses a takeover by the committed lease owner itself (that is a renewal, which needs no clock) and a committed lease whose fields contradict each other. The OS wall clock is not attested and no protection against a compromised clock is claimed. Decision Q1-D1 mirrored on #359, with the one deviation from its letter (the clock reader lives sealed in Core) flagged for the owner. Issue #359. PR #1066. diff --git a/crates/worldscript-secure-storage/Cargo.toml b/crates/worldscript-secure-storage/Cargo.toml index aeed20b6a..a8cff79d3 100644 --- a/crates/worldscript-secure-storage/Cargo.toml +++ b/crates/worldscript-secure-storage/Cargo.toml @@ -35,10 +35,11 @@ zeroize_derive = "~1.4" # Gate 4 slice 4A (ยง11.1): the cross-process `root_commit_mutex` is an operating-system advisory # lock. rustix's safe `flock` on Unix (1.x, MSRV 1.63, already in the lockfile); on Windows one # `LockFileEx` call through windows-sys 0.52 (MSRV 1.56, already in the lockfile). -# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls; on Windows the relative `NtCreateFile` open +# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls (and, with `process`, `geteuid` for the +# quarantine directory of the test-only removal); on Windows the relative `NtCreateFile` open # (the `Wdk_*` features of windows-sys, no new package) and a few more calls. [target.'cfg(unix)'.dependencies] -rustix = { version = "1.1", default-features = false, features = ["fs", "std"] } +rustix = { version = "1.1", default-features = false, features = ["fs", "process", "std"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Wdk_Foundation", "Wdk_Storage_FileSystem"] } diff --git a/crates/worldscript-secure-storage/src/pinned/mod.rs b/crates/worldscript-secure-storage/src/pinned/mod.rs index e4292448c..2afb57068 100644 --- a/crates/worldscript-secure-storage/src/pinned/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/mod.rs @@ -29,12 +29,16 @@ use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Component, Path, PathBuf}; -use crate::durable::EntryKind; +use crate::durable::{DirectoryDurability, EntryKind}; #[cfg(any(windows, test))] mod dirbuf; #[cfg(any(windows, test))] mod names; +#[cfg(feature = "test-support")] +mod removal; +#[cfg(feature = "test-support")] +pub use removal::{Observer, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; #[cfg(unix)] mod unix; #[cfg(unix)] @@ -192,6 +196,12 @@ impl PinnedDir { sys::entries(&self.handle, &self.path, limit) } + /// Persists the entries of this directory where the platform can confirm it (`fsync` on the + /// directory on Unix; Windows offers no directory sync, so the change is not confirmed durable). + pub fn sync(&self) -> Result { + sys::sync_dir(&self.handle) + } + /// Whether [`path`](Self::path) still names this very directory. `false` when it was renamed away, /// replaced or removed, and also when an ancestor was turned into a link (Unix: the path is walked /// component by component without following a link). diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs new file mode 100644 index 000000000..df254eb54 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -0,0 +1,148 @@ +//! Gate 4 slice H1a-2, test-support only: removal that is bound to the object that was authenticated. +//! +//! **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. +//! +//! **The two designs this replaces, and why.** 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 +//! has no unlink-by-inode, so the call removes whatever is bound to the name at that instant, which may +//! be a live object another actor put there after the check. `FILE_FLAG_DELETE_ON_CLOSE` is chosen when +//! the handle is opened and cannot be taken back, so a file opened with it before the authentication is +//! deleted when the handle closes even if the authentication fails. +//! +//! **Unix: move aside, identify, then unlink.** The authenticated file is held open with identity `I`. +//! A quarantine directory (a random name, mode 0700, owned by the process; this is a convenience, not +//! exclusivity: another process of the same user can find and enter it) is created in the pinned parent +//! and held by descriptor, and `renameat2(RENAME_NOREPLACE)` moves whatever the name is bound to *right +//! now* into it, atomically. Only there is the object identified, by opening it and comparing the +//! identity of the open object. If it is the authenticated object it is unlinked from the quarantine +//! directory, and the link count of the open object proves afterwards that this very object lost a name; +//! if it is anything else (a live file, a link, a directory) it is moved back under its name, or left +//! in the quarantine directory if the name was taken meanwhile, and in no case unlinked. +//! +//! *Residual, stated plainly.* No Unix primitive unlinks "this inode": `unlinkat` removes the name's +//! current binding. Between the last identification and the unlink, an actor that can write into the +//! quarantine directory (same user; it has to find the random name) and wins that one-syscall race +//! can have its own regular file unlinked instead. That is detected after the fact (the link count of +//! the authenticated object did not drop) and reported as [`RemovalOutcome::Disturbed`]; it cannot be +//! undone, and it gives that actor nothing it could not do directly (it can unlink its own file and, +//! being the same user, the live one). The observer forces this window in a test, with the outcome pinned. +//! +//! **Windows: authenticate on the handle, then a disposition on that handle.** The object is opened +//! through the same handle-relative lookup as every child, with delete access and a share mode of read +//! only: no other handle can write to it, rename it or delete it while this one is held (a writer that +//! already has it open makes the open fail with [`PinnedError::Busy`]), so the bytes that were +//! authenticated are the bytes that are deleted. Only then `FileDispositionInfoEx` (delete, POSIX +//! semantics) is set on that handle. Failing authentication, an error or dropping the handle deletes +//! nothing; there is no fallback to the classic disposition. +//! +//! 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. + +use std::ffi::OsStr; +use std::path::Path; + +use super::{checked_name, sys, PinnedDir, PinnedError, PinnedFile}; +use crate::durable::DirectoryDurability; + +/// The name prefix of the quarantine directories a removal creates. One that is left behind (a crash, +/// or an object that could not be put back) is never removed by a sweep; see +/// [`PinnedDir::remove_leftover_file`]. +pub const QUARANTINE_PREFIX: &str = ".h1q-"; + +/// What a removal did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemovalOutcome { + /// The authenticated object, and only it, lost this name; the durability of the directory entry + /// change as far as the platform can confirm it. + Removed(DirectoryDurability), + /// The authentication said no; nothing was changed. + Refused, + /// The name no longer led to an object (someone else removed it); nothing was removed. + Vanished, + /// The directory still has entries; nothing was removed. + NotEmpty, + /// Something other than the authenticated object was bound to the name. It was not removed: it is + /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine + /// directory (`.h1q-*`, never removed by a sweep). + Swapped { restored: bool }, + /// Unix only: the unlink removed a name of an object other than the authenticated one (see the + /// residual in the module documentation). Nothing can be undone; the caller must treat the + /// directory as tampered with. + Disturbed, +} + +/// The points of a removal at which a test can act, to force the exact interleavings the primitives are +/// designed against, or to inject a failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemovalStage<'a> { + /// The object was identified and (for a file) authenticated; nothing was changed yet. This is the + /// check-then-use window of a name-based removal. + Checked, + /// Unix only: whatever the name was bound to has been moved into the quarantine directory, as + /// `quarantined`; it has not been identified yet. + MovedAside { quarantined: &'a Path }, + /// Unix only: the object in the quarantine directory was identified as the authenticated one and the + /// unlink is next. This is the last window. + Identified { quarantined: &'a Path }, + /// Unix only: the object is gone and the directory is about to be synced. + BeforeSync, +} + +/// Called at each [`RemovalStage`]. An error stops the removal there: before the object was moved +/// nothing has happened, after it the object is put back (a rollback); after the removal it is reported +/// and a rerun converges. +pub type Observer<'a> = &'a mut dyn FnMut(RemovalStage<'_>) -> Result<(), PinnedError>; + +impl PinnedDir { + /// Removes the regular file `name` if `authenticate` accepts it, and then removes exactly the object + /// that was authenticated. `authenticate` runs on the open file. + pub fn remove_authenticated_file( + &self, + name: impl AsRef, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + ) -> Result { + self.remove_authenticated_file_observed(name, authenticate, &mut |_| Ok(())) + } + + /// [`remove_authenticated_file`](Self::remove_authenticated_file), with `observer` called at each + /// [`RemovalStage`]. + pub fn remove_authenticated_file_observed( + &self, + name: impl AsRef, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + observer: Observer<'_>, + ) -> Result { + let name = checked_name(name.as_ref())?; + sys::removal::remove_file(self, name, authenticate, observer) + } + + /// Removes the child directory `name` if it has no entries, and exactly the directory that was + /// opened. + pub fn remove_empty_dir(&self, name: impl AsRef) -> Result { + self.remove_empty_dir_observed(name, &mut |_| Ok(())) + } + + /// [`remove_empty_dir`](Self::remove_empty_dir), with `observer` called at each [`RemovalStage`]. + pub fn remove_empty_dir_observed( + &self, + name: impl AsRef, + observer: Observer<'_>, + ) -> Result { + let name = checked_name(name.as_ref())?; + sys::removal::remove_dir(self, name, observer) + } + + /// Finishes what a crash or a refused put-back left in the quarantine directory `quarantine` (a + /// `.h1q-*` entry of this directory): the file in it is authenticated again and, if `authenticate` + /// accepts it, removed like any other. A file that is not accepted stays; an empty quarantine + /// directory is removed. Unix only: Windows creates none (`Unsupported`). + pub fn remove_leftover_file( + &self, + quarantine: impl AsRef, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + ) -> Result { + let quarantine = checked_name(quarantine.as_ref())?; + sys::removal::remove_leftover(self, quarantine, authenticate, &mut |_| Ok(())) + } +} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index 5c044c1e7..1acddc1a7 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -6,6 +6,12 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; mod read; +#[cfg(feature = "test-support")] +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/read.rs b/crates/worldscript-secure-storage/src/pinned/tests/read.rs index e700ea8b4..b13b7ab9d 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/read.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/read.rs @@ -497,3 +497,17 @@ fn every_operation_follows_the_held_directory_not_the_path_it_was_stored_with() ) ); } + +#[test] +fn a_directory_can_be_synced_where_the_platform_confirms_it() { + use crate::durable::DirectoryDurability; + + let scratch = Scratch::new("sync"); + let pinned = PinnedDir::open_root(&scratch.root()).unwrap(); + let expected = if cfg!(unix) { + DirectoryDurability::Confirmed + } else { + DirectoryDurability::NotConfirmed + }; + assert_eq!(pinned.sync(), Ok(expected)); +} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs new file mode 100644 index 000000000..e782aaabe --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs @@ -0,0 +1,200 @@ +//! Removal on every platform: only the authenticated object goes, and a refusal changes nothing. + +use std::fs; + +use super::{link_to_dir, Scratch}; +use crate::durable::DirectoryDurability; +use crate::pinned::{PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage}; + +/// Accepts a file whose whole content is `expected`. +pub(super) fn holds( + expected: &'static [u8], +) -> impl FnOnce(&mut PinnedFile) -> Result { + move |file| Ok(file.read_at_most(64)? == expected) +} + +/// What a completed removal reports on this platform. +pub(super) fn removed() -> Result { + let durability = if cfg!(unix) { + DirectoryDurability::Confirmed + } else { + DirectoryDurability::NotConfirmed + }; + Ok(RemovalOutcome::Removed(durability)) +} + +pub(super) fn names(pinned: &PinnedDir) -> Vec { + let mut listed: Vec = pinned + .entries(32) + .unwrap() + .into_iter() + .map(|name| name.to_string_lossy().into_owned()) + .collect(); + listed.sort(); + listed +} + +#[test] +fn an_authenticated_file_is_removed_and_nothing_else_is() { + let scratch = Scratch::new("remove"); + 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 = pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + (outcome, names(&pinned), fs::read(root.join("b")).unwrap()), + (removed(), vec!["b".to_string()], b"beta".to_vec()) + ); +} + +#[test] +fn a_refused_authentication_a_failing_one_and_a_wrong_identity_remove_nothing() { + let scratch = Scratch::new("refuse"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::write(root.join("other"), b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let expected = pinned.open_file("other").unwrap().identity(); + let refused = pinned.remove_authenticated_file("a", holds(b"something else")); + let failed = pinned.remove_authenticated_file("a", |_| Err(PinnedError::Busy)); + // The bytes match, but this is not the object the caller meant. + let wrong = pinned.remove_authenticated_file("a", move |file| Ok(file.identity() == expected)); + assert_eq!( + ( + refused, + failed, + wrong, + fs::read(root.join("a")).unwrap(), + names(&pinned) + ), + ( + Ok(RemovalOutcome::Refused), + Err(PinnedError::Busy), + Ok(RemovalOutcome::Refused), + b"alpha".to_vec(), + vec!["a".to_string(), "other".to_string()] + ) + ); +} + +#[test] +fn a_link_a_directory_and_a_missing_name_are_not_removed_as_files() { + let scratch = Scratch::new("not-files"); + let root = scratch.root(); + let aside = scratch.aside(); + fs::create_dir(aside.join("target")).unwrap(); + fs::write(aside.join("target").join("kept"), b"kept").unwrap(); + link_to_dir(&aside.join("target"), &root.join("link")); + fs::create_dir(root.join("dir")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let remove = |name: &str| pinned.remove_authenticated_file(name, |_| Ok(true)); + assert_eq!( + ( + remove("link"), + remove("dir"), + remove("missing"), + fs::read(aside.join("target").join("kept")).unwrap(), + names(&pinned) + ), + ( + Err(PinnedError::Redirected), + Err(PinnedError::NotRegularFile), + Err(PinnedError::NotFound), + b"kept".to_vec(), + vec!["dir".to_string(), "link".to_string()] + ) + ); +} + +#[test] +fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() { + let scratch = Scratch::new("dirs"); + let root = scratch.root(); + let aside = scratch.aside(); + fs::create_dir(root.join("empty")).unwrap(); + fs::create_dir(root.join("filled")).unwrap(); + fs::write(root.join("filled").join("inside"), b"inside").unwrap(); + fs::create_dir(aside.join("target")).unwrap(); + link_to_dir(&aside.join("target"), &root.join("link")); + let pinned = PinnedDir::open_root(&root).unwrap(); + assert_eq!( + ( + pinned.remove_empty_dir("empty"), + pinned.remove_empty_dir("filled"), + pinned.remove_empty_dir("link"), + pinned.remove_empty_dir("missing"), + fs::read(root.join("filled").join("inside")).unwrap(), + aside.join("target").is_dir(), + names(&pinned) + ), + ( + removed(), + Ok(RemovalOutcome::NotEmpty), + Err(PinnedError::Redirected), + Err(PinnedError::NotFound), + b"inside".to_vec(), + true, + vec!["filled".to_string(), "link".to_string()] + ) + ); +} + +#[test] +fn removing_a_hard_linked_name_leaves_the_other_names() { + let scratch = Scratch::new("hard-link"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::hard_link(root.join("a"), root.join("a-too")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let links = pinned.open_file("a").unwrap().link_count(); + let outcome = pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + ( + links, + outcome, + names(&pinned), + fs::read(root.join("a-too")).unwrap(), + pinned.open_file("a-too").unwrap().link_count() + ), + ( + Ok(2), + removed(), + vec!["a-too".to_string()], + b"alpha".to_vec(), + Ok(1) + ) + ); +} + +#[test] +fn a_failure_injected_before_anything_changed_changes_nothing() { + let scratch = Scratch::new("inject-checked"); + 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 denied = || Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)); + let file = pinned.remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if stage == RemovalStage::Checked { + return denied(); + } + Ok(()) + }); + let dir = pinned.remove_empty_dir_observed("d", &mut |stage| { + if stage == RemovalStage::Checked { + return denied(); + } + Ok(()) + }); + let expected = Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)); + assert_eq!( + (file, dir, names(&pinned), fs::read(root.join("a")).unwrap()), + ( + expected, + expected, + vec!["a".to_string(), "d".to_string()], + b"alpha".to_vec() + ) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs new file mode 100644 index 000000000..378d1cc6c --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -0,0 +1,613 @@ +//! Unix removal under the swap the design exists for: another actor replaces the last component of the +//! name, or the object inside the quarantine directory, between the steps of the removal. Each case +//! forces the swap through the observer at a chosen stage and pins what happens to every object. The +//! counter-proofs show that the swap really takes place (the baseline, and the asserted state of the +//! displaced objects). + +use std::fs; +use std::os::fd::AsFd; +use std::os::unix::fs::PermissionsExt; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; + +use rustix::fs::{unlinkat, AtFlags}; + +use super::remove::{holds, names, removed}; +use super::Scratch; +use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; + +/// A pinned directory holding `a` = "alpha", with the paths a swap needs. +struct Fixture { + /// Keeps the directories until the test ends. + _scratch: Scratch, + root: PathBuf, + aside: PathBuf, + pinned: PinnedDir, +} + +fn fixture(label: &str) -> Fixture { + let scratch = Scratch::new(label); + let root = scratch.root(); + let aside = scratch.aside(); + fs::write(root.join("a"), b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + Fixture { + _scratch: scratch, + root, + aside, + pinned, + } +} + +/// The stages of a removal, without their payload. +#[derive(Clone, Copy, PartialEq)] +enum At { + Checked, + MovedAside, + Identified, + BeforeSync, +} + +/// An observer that runs `action` (with the path inside the quarantine directory, once there is one) at +/// the stage `at` and otherwise does nothing. +fn on( + at: At, + mut action: impl FnMut(Option<&Path>), +) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { + move |stage| { + let (here, path) = match stage { + RemovalStage::Checked => (At::Checked, None), + RemovalStage::MovedAside { quarantined } => (At::MovedAside, Some(quarantined)), + RemovalStage::Identified { quarantined } => (At::Identified, Some(quarantined)), + RemovalStage::BeforeSync => (At::BeforeSync, None), + }; + if here == at { + action(path); + } + Ok(()) + } +} + +/// An observer that fails at the stage `at`. +fn failing_at(at: At) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { + move |stage| { + let here = match stage { + RemovalStage::Checked => At::Checked, + RemovalStage::MovedAside { .. } => At::MovedAside, + RemovalStage::Identified { .. } => At::Identified, + RemovalStage::BeforeSync => At::BeforeSync, + }; + if here == at { + return Err(denied()); + } + Ok(()) + } +} + +fn denied() -> PinnedError { + PinnedError::Io(std::io::ErrorKind::PermissionDenied) +} + +/// The last-component swap: the original is renamed out of the way and a live file takes its name. +fn swap_in_a_live_file(root: &Path, aside: &Path) { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::write(root.join("a"), b"live").unwrap(); +} + +/// The quarantine directories a removal left in `root`. +fn quarantines(root: &Path) -> Vec { + let mut found: Vec = fs::read_dir(root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with(QUARANTINE_PREFIX) + }) + .collect(); + found.sort(); + found +} + +/// The content of the object left in the only quarantine directory of `root`. +fn held_in_quarantine(root: &Path) -> Option> { + let all = quarantines(root); + assert!(all.len() <= 1, "{all:?}"); + fs::read(all.first()?.join(super::super::unix::removal::INSIDE)).ok() +} + +/// A name-based removal: identify the entry, let `swap` run, then unlink the name. +fn naive_removal( + pinned: &PinnedDir, + name: &str, + swap: impl FnOnce(), +) -> Result<(), rustix::io::Errno> { + let authenticated = pinned.open_file(name).unwrap(); + assert_eq!( + pinned.entry_kind(name), + Ok(Some(crate::durable::EntryKind::File)) + ); + swap(); + drop(authenticated); + unlinkat(pinned.handle.as_fd(), name, AtFlags::empty()) +} + +#[test] +fn the_swap_destroys_the_live_file_of_a_name_based_removal_so_the_hook_forces_the_race() { + let f = fixture("baseline"); + let result = naive_removal(&f.pinned, "a", || swap_in_a_live_file(&f.root, &f.aside)); + assert_eq!( + ( + result, + f.root.join("a").exists(), + fs::read(f.aside.join("original")).unwrap() + ), + (Ok(()), false, b"alpha".to_vec()) + ); +} + +#[test] +fn a_swap_of_the_last_name_after_the_check_destroys_nothing() { + let f = fixture("swap"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)), + ); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + fs::read(f.aside.join("original")).unwrap(), + names(&f.pinned) + ), + ( + Ok(RemovalOutcome::Swapped { restored: true }), + b"live".to_vec(), + b"alpha".to_vec(), + vec!["a".to_string()] + ) + ); +} + +/// A swap, and whether what it put at the name is still there afterwards. +type Swap = (&'static str, fn(&Path, &Path), fn(&Path, &Path) -> bool); + +#[test] +fn a_directory_a_link_or_a_fifo_swapped_in_is_put_back_untouched() { + use std::os::unix::fs::FileTypeExt; + + let swaps: [Swap; 3] = [ + ( + "directory", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::create_dir(root.join("a")).unwrap(); + fs::write(root.join("a").join("inside"), b"inside").unwrap(); + }, + |root, _| fs::read(root.join("a").join("inside")).ok() == Some(b"inside".to_vec()), + ), + ( + "symlink", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::write(aside.join("target"), b"target").unwrap(); + std::os::unix::fs::symlink(aside.join("target"), root.join("a")).unwrap(); + }, + |root, aside| { + fs::read_link(root.join("a")).ok() == Some(aside.join("target")) + && fs::read(aside.join("target")).ok() == Some(b"target".to_vec()) + }, + ), + ( + "fifo", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + let made = std::process::Command::new("mkfifo") + .arg(root.join("a")) + .status() + .unwrap(); + assert!(made.success()); + }, + |root, _| fs::symlink_metadata(root.join("a")).is_ok_and(|m| m.file_type().is_fifo()), + ), + ]; + let outcomes: Vec<_> = swaps + .into_iter() + .map(|(label, swap, intact)| { + let f = fixture(label); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| swap(&f.root, &f.aside)), + ); + ( + label, + outcome, + intact(&f.root, &f.aside), + fs::read(f.aside.join("original")).unwrap(), + ) + }) + .collect(); + let put_back = |label| { + ( + label, + Ok(RemovalOutcome::Swapped { restored: true }), + true, + b"alpha".to_vec(), + ) + }; + assert_eq!( + outcomes, + [put_back("directory"), put_back("symlink"), put_back("fifo")] + ); +} + +#[test] +fn a_name_taken_again_while_the_swapped_file_is_aside_leaves_every_object_intact() { + let f = fixture("retaken"); + let mut at_checked = on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)); + let mut at_moved = on(At::MovedAside, |_| { + fs::write(f.root.join("a"), b"retaken").unwrap() + }); + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + at_checked(stage)?; + at_moved(stage) + }); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + fs::read(f.aside.join("original")).unwrap(), + held_in_quarantine(&f.root) + ), + ( + Ok(RemovalOutcome::Swapped { restored: false }), + b"retaken".to_vec(), + b"alpha".to_vec(), + Some(b"live".to_vec()) + ) + ); +} + +#[test] +fn a_swap_inside_the_quarantine_directory_before_the_identification_is_never_unlinked() { + let f = fixture("inside"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let quarantined = quarantined.unwrap(); + fs::rename(quarantined, f.aside.join("moved-original")).unwrap(); + fs::write(quarantined, b"foreign").unwrap(); + }), + ); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + fs::read(f.aside.join("moved-original")).unwrap(), + names(&f.pinned) + ), + ( + Ok(RemovalOutcome::Swapped { restored: true }), + b"foreign".to_vec(), + b"alpha".to_vec(), + vec!["a".to_string()] + ) + ); +} + +#[test] +fn the_last_window_is_forced_and_a_foreign_file_unlinked_in_it_is_reported() { + // No Unix primitive unlinks "this inode": after the last identification the unlink removes whatever + // the name is bound to. A writer of the quarantine directory that wins this one race loses nothing + // it could not destroy itself, and the removal says so instead of reporting success. + let f = fixture("window"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Identified, |quarantined| { + let quarantined = quarantined.unwrap(); + fs::rename(quarantined, f.aside.join("original")).unwrap(); + fs::write(quarantined, b"foreign").unwrap(); + }), + ); + assert_eq!( + ( + outcome, + f.root.join("a").exists(), + fs::read(f.aside.join("original")).unwrap(), + quarantines(&f.root).len() + ), + (Ok(RemovalOutcome::Disturbed), false, b"alpha".to_vec(), 0) + ); +} + +#[test] +fn a_directory_in_the_last_window_is_never_unlinked_and_goes_back() { + let f = fixture("window-dir"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Identified, |quarantined| { + let quarantined = quarantined.unwrap(); + fs::rename(quarantined, f.aside.join("original")).unwrap(); + fs::create_dir(quarantined).unwrap(); + fs::write(quarantined.join("inside"), b"inside").unwrap(); + }), + ); + assert_eq!( + ( + outcome.is_err(), + fs::read(f.root.join("a").join("inside")).unwrap(), + fs::read(f.aside.join("original")).unwrap(), + quarantines(&f.root).len() + ), + (true, b"inside".to_vec(), b"alpha".to_vec(), 0) + ); +} + +#[test] +fn a_hard_link_made_in_the_last_window_leaves_the_object_alive_under_that_name() { + let f = fixture("window-link"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Identified, |quarantined| { + fs::hard_link(quarantined.unwrap(), f.aside.join("extra")).unwrap(); + }), + ); + assert_eq!( + ( + outcome, + names(&f.pinned), + fs::read(f.aside.join("extra")).unwrap() + ), + (removed(), Vec::::new(), b"alpha".to_vec()) + ); +} + +#[test] +fn a_name_taken_after_the_authenticated_file_moved_aside_keeps_the_new_file() { + let f = fixture("taken-after"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |_| { + fs::write(f.root.join("a"), b"new").unwrap() + }), + ); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + names(&f.pinned) + ), + (removed(), b"new".to_vec(), vec!["a".to_string()]) + ); +} + +#[test] +fn a_name_that_vanishes_after_the_check_is_reported_and_leaves_nothing_behind() { + let f = fixture("vanish"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| fs::remove_file(f.root.join("a")).unwrap()), + ); + assert_eq!( + (outcome, names(&f.pinned)), + (Ok(RemovalOutcome::Vanished), Vec::::new()) + ); +} + +#[test] +fn renaming_the_root_during_the_removal_does_not_redirect_it() { + let f = fixture("root-renamed"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| { + fs::rename(&f.root, f.aside.join("moved-root")).unwrap(); + fs::create_dir(&f.root).unwrap(); + fs::write(f.root.join("a"), b"decoy").unwrap(); + }), + ); + let moved = fs::read_dir(f.aside.join("moved-root")).unwrap().count(); + assert_eq!( + (outcome, fs::read(f.root.join("a")).unwrap(), moved), + (removed(), b"decoy".to_vec(), 0) + ); +} + +#[test] +fn a_directory_swapped_or_filled_after_the_check_is_put_back_and_not_removed() { + let f = fixture("dir-swap"); + fs::create_dir(f.root.join("d")).unwrap(); + fs::create_dir(f.root.join("e")).unwrap(); + let swapped = f.pinned.remove_empty_dir_observed( + "d", + &mut on(At::Checked, |_| { + fs::rename(f.root.join("d"), f.aside.join("original-d")).unwrap(); + fs::create_dir(f.root.join("d")).unwrap(); + fs::write(f.root.join("d").join("live"), b"live").unwrap(); + }), + ); + let filled = f.pinned.remove_empty_dir_observed( + "e", + &mut on(At::Checked, |_| { + fs::write(f.root.join("e").join("late"), b"late").unwrap() + }), + ); + assert_eq!( + ( + swapped, + fs::read(f.root.join("d").join("live")).unwrap(), + f.aside.join("original-d").is_dir(), + filled, + fs::read(f.root.join("e").join("late")).unwrap(), + names(&f.pinned) + ), + ( + Ok(RemovalOutcome::Swapped { restored: true }), + b"live".to_vec(), + true, + Ok(RemovalOutcome::NotEmpty), + b"late".to_vec(), + vec!["a".to_string(), "d".to_string(), "e".to_string()] + ) + ); +} + +#[test] +fn the_quarantine_directory_is_closed_to_other_users_and_gone_afterwards() { + let f = fixture("private"); + let mut seen = None; + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let directory = quarantined.unwrap().parent().unwrap(); + seen = Some(( + directory + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(QUARANTINE_PREFIX), + fs::metadata(directory).unwrap().permissions().mode() & 0o777, + )); + }), + ); + assert_eq!( + (outcome, seen, quarantines(&f.root).len()), + (removed(), Some((true, 0o700)), 0) + ); +} + +#[test] +fn a_failure_after_the_move_puts_the_object_back_and_leaves_no_quarantine_directory() { + let outcomes: Vec<_> = [At::MovedAside, At::Identified] + .into_iter() + .map(|at| { + let f = fixture("rollback"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut failing_at(at), + ); + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + quarantines(&f.root).len(), + ) + }) + .collect(); + assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 2]); +} + +#[test] +fn a_failed_sync_is_reported_although_the_object_is_gone_and_a_rerun_converges() { + let f = fixture("sync"); + let first = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut failing_at(At::BeforeSync), + ); + let rerun = f.pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + (first, rerun, names(&f.pinned)), + ( + Err(denied()), + Err(PinnedError::NotFound), + Vec::::new() + ) + ); +} + +/// A removal that "crashes" (the observer panics) once the object is in the quarantine directory. +fn crash_after_the_move(f: &Fixture) { + let crashed = catch_unwind(AssertUnwindSafe(|| { + let _ = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |_| panic!("simulated crash")), + ); + })); + assert!(crashed.is_err()); +} + +#[test] +fn a_crash_leaves_the_object_in_quarantine_and_a_leftover_removal_finishes_it_after_authenticating() +{ + let f = fixture("crash"); + crash_after_the_move(&f); + let left = quarantines(&f.root); + let name = left[0].file_name().unwrap().to_owned(); + let before = (names(&f.pinned).len(), held_in_quarantine(&f.root)); + let refused = f + .pinned + .remove_leftover_file(&name, holds(b"something else")); + let finished = f.pinned.remove_leftover_file(&name, holds(b"alpha")); + assert_eq!( + (before, refused, finished, names(&f.pinned)), + ( + (1, Some(b"alpha".to_vec())), + Ok(RemovalOutcome::Refused), + removed(), + Vec::::new() + ) + ); +} + +#[test] +fn a_leftover_is_taken_by_its_name_only_and_an_empty_one_is_removed() { + let f = fixture("leftover-names"); + fs::create_dir(f.root.join(".h1q-empty")).unwrap(); + fs::set_permissions(f.root.join(".h1q-empty"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::create_dir(f.root.join("not-a-quarantine")).unwrap(); + let taken = |name: &str| f.pinned.remove_leftover_file(name, |_| Ok(true)); + assert_eq!( + ( + taken("a"), + taken("not-a-quarantine"), + taken(".h1q-empty"), + names(&f.pinned) + ), + ( + Err(PinnedError::InvalidName), + Err(PinnedError::InvalidName), + Ok(RemovalOutcome::Vanished), + vec!["a".to_string(), "not-a-quarantine".to_string()] + ) + ); +} + +#[test] +fn a_foreign_object_left_in_quarantine_is_never_removed_by_a_leftover_removal() { + let f = fixture("leftover-foreign"); + let mut at_checked = on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)); + let mut at_moved = on(At::MovedAside, |_| { + fs::write(f.root.join("a"), b"retaken").unwrap() + }); + let first = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + at_checked(stage)?; + at_moved(stage) + }); + let name = quarantines(&f.root)[0].file_name().unwrap().to_owned(); + // The object in the quarantine directory is the live file, not an object of the sweep. + let refused = f.pinned.remove_leftover_file(&name, holds(b"alpha")); + assert_eq!( + (first, refused, held_in_quarantine(&f.root)), + ( + Ok(RemovalOutcome::Swapped { restored: false }), + Ok(RemovalOutcome::Refused), + Some(b"live".to_vec()) + ) + ); +} 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/unix.rs b/crates/worldscript-secure-storage/src/pinned/unix.rs index 685680868..d0c757619 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix.rs @@ -12,7 +12,10 @@ use rustix::fs::{fstat, open, openat, statat, AtFlags, Dir, FileType, Mode, OFla use rustix::io::Errno; use super::{ObjectIdentity, PinnedError}; -use crate::durable::EntryKind; +use crate::durable::{DirectoryDurability, EntryKind}; + +#[cfg(feature = "test-support")] +pub(super) mod removal; pub(super) type DirHandle = File; @@ -210,6 +213,11 @@ pub(super) fn is_still_named(handle: &DirHandle, path: &Path) -> Result Result { + dir.sync_all().map_err(PinnedError::from)?; + Ok(DirectoryDurability::Confirmed) +} + pub(super) fn link_count(file: &File) -> Result { Ok(file.metadata().map_err(PinnedError::from)?.nlink()) } diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs new file mode 100644 index 000000000..3c2f616af --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -0,0 +1,365 @@ +//! Unix removal, test-support only: move the object into a quarantine directory, identify the open +//! object there, and only then unlink it. See the parent `removal` module for the reasoning and for the +//! one residual that no Unix primitive removes. + +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use rustix::fs::{mkdirat, openat, renameat_with, unlinkat, AtFlags, Mode, RenameFlags}; +use rustix::io::Errno; +use rustix::process::geteuid; + +use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; +use crate::durable::{DirectoryDurability, EntryKind}; +use crate::pinned::{ + ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, + QUARANTINE_PREFIX, +}; +use crate::random::{OsRandom, RandomSource}; + +/// The name an object has inside the quarantine directory. +pub(in crate::pinned) const INSIDE: &str = "x"; + +/// A quarantine directory inside the pinned parent, held by descriptor. Whatever it holds is outside +/// every name grammar of the store and is never removed by a sweep. +struct Quarantine { + fd: File, + name: OsString, +} + +/// What is bound to the quarantine name right now. +enum Inside { + Absent, + /// Not the kind that was asked for (a link, a directory instead of a file, a FIFO). + Foreign, + /// Opened, with the identity of the open object. + Object(File, ObjectIdentity), +} + +/// How a removal ended before the durability of the parent directory was settled. +enum Step { + Finished(RemovalOutcome), + Unlinked, +} + +impl Quarantine { + fn create(parent: &DirHandle) -> Result { + let mut bits = [0u8; 16]; + OsRandom + .fill(&mut bits) + .map_err(|_| PinnedError::Unsupported)?; + let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); + let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); + mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; + match Self::open_private(parent, &name) { + Ok(fd) => Ok(Quarantine { fd, name }), + Err(error) => { + let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); + Err(error) + } + } + } + + /// An existing quarantine directory of `parent`, found by its name. + fn open_existing(parent: &DirHandle, name: &OsStr) -> Result { + if !name.as_bytes().starts_with(QUARANTINE_PREFIX.as_bytes()) { + return Err(PinnedError::InvalidName); + } + let fd = Self::open_private(parent, name)?; + Ok(Quarantine { + fd, + name: name.to_os_string(), + }) + } + + /// The directory `name`, if it is a directory of this process's user that has no access for group + /// or others. That keeps out a directory of another user that was swapped in; it does not keep out + /// the same user, which the removal does not rely on. + fn open_private(parent: &DirHandle, name: &OsStr) -> Result { + let fd = File::from(openat(parent, name, dir_flags(), Mode::empty()).map_err(io_error)?); + let stat = rustix::fs::fstat(&fd).map_err(io_error)?; + let private = kind_of(&stat) == EntryKind::Directory + && stat.st_uid == geteuid().as_raw() + && widen(stat.st_mode)? & 0o077 == 0; + if private { + Ok(fd) + } else { + Err(PinnedError::Unsupported) + } + } + + /// Moves what `name` of `parent` is bound to right now into this directory; `false` if the name + /// leads nowhere. + fn adopt(&self, parent: &DirHandle, name: &OsStr) -> Result { + match renameat_with(parent, name, &self.fd, INSIDE, RenameFlags::NOREPLACE) { + Ok(()) => Ok(true), + Err(Errno::NOENT) => Ok(false), + // No RENAME_NOREPLACE here: nothing was moved, so nothing can be identified safely. + Err(Errno::INVAL | Errno::NOSYS | Errno::NOTSUP) => Err(PinnedError::Unsupported), + Err(errno) => Err(io_error(errno)), + } + } + + /// Looks at the entry before it is opened (opening a device node runs its driver), then opens it + /// without following a link and reports the identity of the open object. + fn inspect(&self, kind: EntryKind) -> Result { + let inside = OsStr::new(INSIDE); + match stat_child(&self.fd, inside)? { + None => return Ok(Inside::Absent), + Some(stat) if kind_of(&stat) != kind => return Ok(Inside::Foreign), + Some(_) => {} + } + let flags = if kind == EntryKind::Directory { + dir_flags() + } else { + file_flags() + }; + let object = match openat(&self.fd, inside, flags, Mode::empty()) { + Ok(fd) => File::from(fd), + Err(Errno::NOENT) => return Ok(Inside::Absent), + // Swapped between the look and the open. + Err(Errno::LOOP | Errno::NOTDIR | Errno::ISDIR | Errno::NXIO) => { + return Ok(Inside::Foreign) + } + Err(errno) => return Err(io_error(errno)), + }; + let metadata = object.metadata().map_err(PinnedError::from)?; + let right_kind = if kind == EntryKind::Directory { + metadata.is_dir() + } else { + metadata.is_file() + }; + if !right_kind { + return Ok(Inside::Foreign); + } + let identity = ObjectIdentity::new(metadata.dev(), u128::from(metadata.ino())); + Ok(Inside::Object(object, identity)) + } + + /// Moves the object back under `name`; `false` if the name is taken (or anything else prevents it), + /// in which case the object stays here. + fn restore(&self, parent: &DirHandle, name: &OsStr) -> bool { + renameat_with(&self.fd, INSIDE, parent, name, RenameFlags::NOREPLACE).is_ok() + } + + fn path_inside(&self, parent_path: &Path) -> PathBuf { + parent_path.join(&self.name).join(INSIDE) + } + + /// Removes this directory if it is empty. A directory that still holds an object is left. + fn discard(self, parent: &DirHandle) { + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + } +} + +/// Unlinks the object `object` that was identified in the quarantine directory. Afterwards the link +/// count of the open file must have dropped by one: if it did not, the name was bound to another +/// object when it was unlinked. +fn unlink_verified( + quarantine: &Quarantine, + object: &File, + kind: EntryKind, +) -> Result { + if kind == EntryKind::Directory { + return match unlinkat(&quarantine.fd, INSIDE, AtFlags::REMOVEDIR) { + Ok(()) => Ok(RemovalOutcome::Removed(DirectoryDurability::Confirmed)), + Err(Errno::NOTEMPTY | Errno::EXIST) => Ok(RemovalOutcome::NotEmpty), + Err(Errno::NOENT) => Ok(RemovalOutcome::Vanished), + Err(errno) => Err(io_error(errno)), + }; + } + let before = object.metadata().map_err(PinnedError::from)?.nlink(); + match unlinkat(&quarantine.fd, INSIDE, AtFlags::empty()) { + Ok(()) => {} + Err(Errno::NOENT) => return Ok(RemovalOutcome::Vanished), + Err(errno) => return Err(io_error(errno)), + } + let after = object.metadata().map_err(PinnedError::from)?.nlink(); + if after + 1 == before { + Ok(RemovalOutcome::Removed(DirectoryDurability::Confirmed)) + } else { + Ok(RemovalOutcome::Disturbed) + } +} + +pub(in crate::pinned) fn remove_file( + dir: &PinnedDir, + name: &OsStr, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + observer: Observer<'_>, +) -> Result { + let mut file = dir.open_file(name)?; + if !authenticate(&mut file)? { + return Ok(RemovalOutcome::Refused); + } + observer(RemovalStage::Checked)?; + remove_moved(dir, name, file.identity(), EntryKind::File, observer) +} + +pub(in crate::pinned) fn remove_dir( + dir: &PinnedDir, + name: &OsStr, + observer: Observer<'_>, +) -> Result { + let child = dir.open_dir(name)?; + match child.entries(0) { + Ok(_) => {} + Err(PinnedError::TooManyEntries) => return Ok(RemovalOutcome::NotEmpty), + Err(error) => return Err(error), + } + observer(RemovalStage::Checked)?; + remove_moved(dir, name, child.identity(), EntryKind::Directory, observer) +} + +/// Removes the leftover file of the quarantine directory `quarantine` after authenticating it again. +pub(in crate::pinned) fn remove_leftover( + dir: &PinnedDir, + quarantine: &OsStr, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + observer: Observer<'_>, +) -> Result { + let held = Quarantine::open_existing(&dir.handle, quarantine)?; + let step = leftover_step(&held, dir, authenticate, observer); + held.discard(&dir.handle); + settle(dir, step?, observer) +} + +fn leftover_step( + held: &Quarantine, + dir: &PinnedDir, + authenticate: impl FnOnce(&mut PinnedFile) -> Result, + observer: Observer<'_>, +) -> Result { + let (file, identity) = match held.inspect(EntryKind::File)? { + Inside::Absent => return Ok(Step::Finished(RemovalOutcome::Vanished)), + Inside::Foreign => return Ok(Step::Finished(RemovalOutcome::Refused)), + Inside::Object(file, identity) => (file, identity), + }; + let mut pinned = PinnedFile { file, identity }; + if !authenticate(&mut pinned)? { + return Ok(Step::Finished(RemovalOutcome::Refused)); + } + observer(RemovalStage::Identified { + quarantined: &held.path_inside(&dir.path), + })?; + unlinked(unlink_verified(held, &pinned.file, EntryKind::File)?) +} + +/// Removes the entry `name` of `dir` if, once moved aside, it is the object `expected` of kind `kind`. +fn remove_moved( + dir: &PinnedDir, + name: &OsStr, + expected: ObjectIdentity, + kind: EntryKind, + observer: Observer<'_>, +) -> Result { + let quarantine = Quarantine::create(&dir.handle)?; + let step = detach(&quarantine, dir, name, expected, kind, observer); + quarantine.discard(&dir.handle); + settle(dir, step?, observer) +} + +fn detach( + quarantine: &Quarantine, + dir: &PinnedDir, + name: &OsStr, + expected: ObjectIdentity, + kind: EntryKind, + observer: Observer<'_>, +) -> Result { + if !quarantine.adopt(&dir.handle, name)? { + return Ok(Step::Finished(RemovalOutcome::Vanished)); + } + let inside = quarantine.path_inside(&dir.path); + rolled_back( + quarantine, + dir, + name, + observer(RemovalStage::MovedAside { + quarantined: &inside, + }), + )?; + match quarantine.inspect(kind)? { + Inside::Absent => Ok(Step::Finished(RemovalOutcome::Vanished)), + Inside::Object(object, found) if found == expected => { + conclude(quarantine, dir, name, &object, kind, observer) + } + Inside::Object(..) | Inside::Foreign => { + let restored = quarantine.restore(&dir.handle, name); + Ok(Step::Finished(RemovalOutcome::Swapped { restored })) + } + } +} + +/// The last window: the object in the quarantine directory is the authenticated one. +fn conclude( + quarantine: &Quarantine, + dir: &PinnedDir, + name: &OsStr, + object: &File, + kind: EntryKind, + observer: Observer<'_>, +) -> Result { + let inside = quarantine.path_inside(&dir.path); + rolled_back( + quarantine, + dir, + name, + observer(RemovalStage::Identified { + quarantined: &inside, + }), + )?; + match unlink_verified(quarantine, object, kind) { + Ok(RemovalOutcome::NotEmpty) => { + // A directory that filled after the check goes back where it was. + quarantine.restore(&dir.handle, name); + Ok(Step::Finished(RemovalOutcome::NotEmpty)) + } + Ok(outcome) => unlinked(outcome), + Err(error) => { + quarantine.restore(&dir.handle, name); + Err(error) + } + } +} + +fn unlinked(outcome: RemovalOutcome) -> Result { + Ok(match outcome { + RemovalOutcome::Removed(_) => Step::Unlinked, + other => Step::Finished(other), + }) +} + +/// `result`, and if it is an error the object is put back first: a failure after the move leaves the +/// directory as it was found. +fn rolled_back( + quarantine: &Quarantine, + dir: &PinnedDir, + name: &OsStr, + result: Result<(), PinnedError>, +) -> Result<(), PinnedError> { + if result.is_err() { + quarantine.restore(&dir.handle, name); + } + result +} + +/// Settles the durability of the parent directory after the quarantine directory was dealt with. A sync +/// that fails is an error although the object is gone: a crash may undo the removal, and a rerun +/// converges. +fn settle( + dir: &PinnedDir, + step: Step, + observer: Observer<'_>, +) -> Result { + match step { + Step::Finished(outcome) => Ok(outcome), + Step::Unlinked => { + observer(RemovalStage::BeforeSync)?; + Ok(RemovalOutcome::Removed(dir.sync()?)) + } + } +} diff --git a/crates/worldscript-secure-storage/src/pinned/windows.rs b/crates/worldscript-secure-storage/src/pinned/windows.rs index e86df0e07..e3274d89f 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows.rs @@ -19,6 +19,8 @@ //! (nothing: such a directory is empty) into an explicit `Redirected`. pub(in crate::pinned) mod nt; +#[cfg(feature = "test-support")] +pub(super) mod removal; use std::ffi::{OsStr, OsString}; use std::fs::File; @@ -37,7 +39,7 @@ use windows_sys::Win32::Storage::FileSystem::{ use super::names; use super::{ObjectIdentity, PinnedError}; -use crate::durable::EntryKind; +use crate::durable::{DirectoryDurability, EntryKind}; use crate::root_lock::sys::{file_identity, open_as_itself, query_handle, still_named}; pub(super) type DirHandle = File; @@ -218,6 +220,11 @@ pub(super) fn is_still_named(handle: &DirHandle, path: &Path) -> Result Result { + Ok(DirectoryDurability::NotConfirmed) +} + pub(super) fn link_count(file: &File) -> Result { Ok(u64::from( standard_info(file) diff --git a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs new file mode 100644 index 000000000..9ddf54e70 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -0,0 +1,115 @@ +//! 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 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<'_>, +) -> Result { + 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<'_>, +) -> Result { + 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, + _authenticate: impl FnOnce(&mut PinnedFile) -> Result, + _observer: Observer<'_>, +) -> 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/crates/worldscript-secure-storage/tests/feature_isolation_compile_test.rs b/crates/worldscript-secure-storage/tests/feature_isolation_compile_test.rs new file mode 100644 index 000000000..7129c2fa7 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/feature_isolation_compile_test.rs @@ -0,0 +1,174 @@ +//! Gate 4 slice H1a-2: the removal primitives are not part of a build without the test-only +//! `test-support` feature. The proof is by compilation: the crate is built twice (the way a dependent +//! gets it, with default features, and with the feature) and a small crate that names the primitives is +//! compiled against each build. Without the feature it must fail with the error for a name that does not +//! exist; with the feature it must compile; and a control that names only what every build has must +//! compile against both, so a failure cannot come from the harness. (The scan in +//! `feature_isolation_removal_test` shows the same from the sources; this shows it from the compiler.) +//! +//! The nested builds use a target directory of their own, so they never wait for the lock of the build +//! that runs this test, and they respect `Cargo.lock` (`--locked`). + +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const CRATE: &str = "worldscript_secure_storage"; + +/// Names only what every build has. +const CONTROL: &str = "\ +use worldscript_secure_storage::pinned::PinnedDir; +pub fn open(path: &std::path::Path) { + let _ = PinnedDir::open_root(path); +} +"; + +/// Calls a removal primitive. +const NAMES_A_METHOD: &str = "\ +use worldscript_secure_storage::pinned::PinnedDir; +pub fn remove(dir: &PinnedDir) { + let _ = dir.remove_authenticated_file(\"a\", |_| Ok(true)); +} +"; + +/// Imports the result type of a removal. +const NAMES_A_TYPE: &str = "\ +use worldscript_secure_storage::pinned::RemovalOutcome; +pub fn outcome(outcome: RemovalOutcome) -> RemovalOutcome { + outcome +} +"; + +/// A build of the crate: its library and the directory of its dependencies. +struct Build { + rlib: PathBuf, + deps: PathBuf, +} + +fn crate_manifest() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml") +} + +/// The hashed library of this crate in the `deps` directory, found through its metadata file in the +/// `filenames` of the artifact in cargo's JSON output. (The plain `lib.rlib` next to `deps` is a +/// link that the next build with other features overwrites; the hashed file is one per feature set.) +fn rlib_of(json_lines: &str) -> Option { + let line = json_lines.lines().find(|line| { + line.contains("\"reason\":\"compiler-artifact\"") + && line.contains(&format!("\"name\":\"{CRATE}\"")) + && line.contains("\"kind\":[\"lib\"]") + })?; + let names = line.split("\"filenames\":[").nth(1)?.split(']').next()?; + names + .split('"') + .find(|piece| piece.contains("/deps/") && piece.ends_with(".rmeta")) + .map(|metadata| PathBuf::from(metadata.replace(".rmeta", ".rlib"))) +} + +fn build(target_dir: &Path, features: &[&str]) -> Build { + let mut command = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())); + command + .args(["build", "--locked", "--lib", "--message-format=json"]) + .arg("--manifest-path") + .arg(crate_manifest()) + .arg("--target-dir") + .arg(target_dir); + if !features.is_empty() { + command.arg("--features").arg(features.join(",")); + } + // Nothing of the outer build may change how the inner one compiles. + for inherited in ["CARGO_ENCODED_RUSTFLAGS", "RUSTFLAGS", "CARGO_TARGET_DIR"] { + command.env_remove(inherited); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let rlib = rlib_of(&String::from_utf8_lossy(&output.stdout)).expect("the library artifact"); + let deps = rlib.parent().unwrap().to_path_buf(); + Build { rlib, deps } +} + +/// Compiles `source` as a library against `build`; the error text if it does not compile. +fn compile(build: &Build, work: &Path, label: &str, source: &str) -> Result<(), String> { + let file = work.join(format!("{label}.rs")); + fs::write(&file, source).unwrap(); + let output = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into())) + .args([ + "--edition", + "2021", + "--crate-type", + "lib", + "--emit", + "metadata", + ]) + .arg("--out-dir") + .arg(work) + .arg("--crate-name") + .arg(label) + .arg("--extern") + .arg(format!("{CRATE}={}", build.rlib.display())) + .arg("-L") + .arg(format!("dependency={}", build.deps.display())) + .arg(&file) + .output() + .unwrap(); + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).into_owned()) + } +} + +/// A scratch directory below the temp dir, removed when dropped. +struct Work(PathBuf); + +impl Work { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("wss-h1a2-compile-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + Work(path) + } +} + +impl Drop for Work { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +type Verdict = Result<(), String>; + +/// Which of the sources compile against a build: the control, the method and the type. +fn verdicts(build: &Build, work: &Path) -> [Verdict; 3] { + [ + compile(build, work, "control", CONTROL), + compile(build, work, "method", NAMES_A_METHOD), + compile(build, work, "outcome", NAMES_A_TYPE), + ] +} + +#[test] +fn the_removal_primitives_exist_only_in_a_build_with_the_test_support_feature() { + let work = Work::new(); + let target = work.0.join("target"); + let without = build(&target, &[]); + let with = build(&target, &["test-support"]); + let [control, method, outcome] = verdicts(&without, &work.0); + let without_the_feature = ( + control, + method.as_ref().err().map(|error| error.contains("E0599")), + outcome.as_ref().err().map(|error| error.contains("E0432")), + ); + let with_the_feature = verdicts(&with, &work.0); + assert_eq!( + (without_the_feature, with_the_feature), + ((Ok(()), Some(true), Some(true)), [Ok(()), Ok(()), Ok(())]), + "method: {method:?}\noutcome: {outcome:?}" + ); +} diff --git a/crates/worldscript-secure-storage/tests/feature_isolation_removal_test.rs b/crates/worldscript-secure-storage/tests/feature_isolation_removal_test.rs new file mode 100644 index 000000000..536b70cf0 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/feature_isolation_removal_test.rs @@ -0,0 +1,194 @@ +//! Gate 4 slice H1a-2: the primitives that remove an object through a pinned directory exist only with the +//! test-only `test-support` feature, and nothing in the crate calls them. A build without the feature +//! cannot name them at all: the module that holds them is declared under the gate on every platform, and +//! no other source file mentions them. (The no-dependent and default-feature checks of +//! `feature_isolation_test` cover who can request the feature.) + +use std::fs; +use std::path::{Path, PathBuf}; + +const GATE: &str = "#[cfg(feature = \"test-support\")]"; +/// What names a removal primitive, its result or its module. +const REMOVAL_NAMES: [&str; 9] = [ + "RemovalOutcome", + "RemovalStage", + "QUARANTINE_PREFIX", + "remove_authenticated_file", + "remove_empty_dir", + "remove_leftover_file", + "Observer", + "removal::", + "mod removal", +]; +/// What starts a statement or an item in the sources that are scanned. +const STATEMENT_STARTS: [&str; 9] = [ + "pub use ", + "use ", + "mod ", + "pub mod ", + "pub(super) mod ", + "pub fn ", + "fn ", + "impl ", + "pub enum ", +]; +/// The modules that hold the primitives, each declared by the file that is its parent. +const GATED_MODULES: [(&str, &str); 3] = [ + ("src/pinned/removal.rs", "src/pinned/mod.rs"), + ("src/pinned/unix/removal.rs", "src/pinned/unix.rs"), + ("src/pinned/windows/removal.rs", "src/pinned/windows.rs"), +]; + +fn crate_dir() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +/// Every `.rs` file below `dir`, as paths relative to the crate. +fn sources_below(dir: &Path) -> Vec { + let mut found = Vec::new(); + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + found.extend(sources_below(&path)); + } else if path.extension().is_some_and(|extension| extension == "rs") { + found.push(path); + } + } + found +} + +fn relative(path: &Path) -> String { + path.strip_prefix(crate_dir()) + .unwrap() + .to_string_lossy() + .replace('\\', "/") +} + +/// The lines of a source file. +struct Source { + lines: Vec, +} + +impl Source { + fn of(text: &str) -> Self { + Source { + lines: text.lines().map(str::to_owned).collect(), + } + } + + fn read(relative_path: &str) -> Self { + Self::of(&fs::read_to_string(crate_dir().join(relative_path)).unwrap()) + } + + /// The attribute, doc and comment lines directly above line `head`. + fn lines_above(&self, head: usize) -> Vec<&str> { + self.lines[..head] + .iter() + .rev() + .map(|line| line.trim()) + .take_while(|line| line.starts_with("#[") || line.starts_with("//")) + .collect() + } + + fn is_gated(&self, head: usize) -> bool { + self.lines_above(head).contains(&GATE) + } + + /// The statement that holds line `at`: the nearest line at or above it that starts a statement. + fn statement_head(&self, at: usize) -> usize { + (0..=at) + .rev() + .find(|&index| { + let line = self.lines[index].trim_start(); + STATEMENT_STARTS.iter().any(|start| line.starts_with(start)) + }) + .unwrap_or(at) + } + + fn names_a_removal(&self, at: usize) -> bool { + let line = &self.lines[at]; + !line.trim_start().starts_with("//") && REMOVAL_NAMES.iter().any(|name| line.contains(name)) + } + + /// Every non-comment line that names a removal and whose statement is not gated. + fn ungated_mentions(&self) -> Vec<&str> { + (0..self.lines.len()) + .filter(|&at| self.names_a_removal(at)) + .filter(|&at| !self.is_gated(self.statement_head(at))) + .map(|at| self.lines[at].trim()) + .collect() + } + + /// Whether the module `removal` is declared right under the gate. + fn declares_the_gated_module(&self) -> bool { + self.lines + .iter() + .position(|line| line.trim_start().ends_with("mod removal;")) + .is_some_and(|at| self.is_gated(at)) + } +} + +fn is_a_gated_module_file(path: &str) -> bool { + GATED_MODULES.iter().any(|(module, _)| *module == path) || path.starts_with("src/pinned/tests/") +} + +#[test] +fn no_source_outside_the_gated_modules_names_a_removal_without_the_gate() { + let mut ungated = Vec::new(); + for path in sources_below(&crate_dir().join("src")) { + let path = relative(&path); + if is_a_gated_module_file(&path) { + continue; + } + for line in Source::read(&path).ungated_mentions() { + ungated.push(format!("{path}: {line}")); + } + } + assert!(ungated.is_empty(), "{ungated:?}"); +} + +#[test] +fn every_module_that_holds_a_removal_is_declared_under_the_gate() { + let undeclared: Vec<&str> = GATED_MODULES + .iter() + .filter(|(module, parent)| { + !crate_dir().join(module).is_file() || !Source::read(parent).declares_the_gated_module() + }) + .map(|(module, _)| *module) + .collect(); + assert!(undeclared.is_empty(), "{undeclared:?}"); +} + +#[test] +fn no_other_module_named_removal_exists() { + let mut found: Vec = sources_below(&crate_dir().join("src")) + .iter() + .map(|path| relative(path)) + .filter(|path| path.ends_with("/removal.rs")) + .collect(); + found.sort(); + let mut expected: Vec = GATED_MODULES + .iter() + .map(|(module, _)| (*module).to_owned()) + .collect(); + expected.sort(); + assert_eq!(found, expected); +} + +#[test] +fn the_gate_scan_sees_an_ungated_export_and_an_ungated_declaration() { + let gated = + "#[cfg(feature = \"test-support\")]\npub use removal::{RemovalOutcome, RemovalStage};\n"; + let ungated = "pub use removal::{\n RemovalOutcome, RemovalStage,\n};\n"; + let declared = |text: &str| Source::of(text).declares_the_gated_module(); + assert_eq!( + ( + Source::of(gated).ungated_mentions().len(), + Source::of(ungated).ungated_mentions().len(), + declared("#[cfg(feature = \"test-support\")]\nmod removal;\n"), + declared("pub(super) mod removal;\n"), + declared("#[cfg(feature = \"test-support\")]\npub(super) mod removal;\n"), + ), + (0, 2, true, false, true) + ); +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 269f98cdb..797f2ce8d 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1696,3 +1696,96 @@ its result is recorded on the PR. `is_still_named` where that matters. Everything below the root is handle-relative, but reading is not removing: the removal primitives, and with them the claim that only the authenticated object is ever removed, are H1a-2. The sweep (H1b), the three producer-equivalence invariants (H1c) and stored-set reclamation (I1c) are not started. + +### Bound removal through a pinned directory (Gate 4 slice H1a-2) + +Status words: `H1A_2_REMOVAL=IMPLEMENTED_TEST_SUPPORT_ONLY`, `H1_PROVED=NO` (H1b has not ported the sweep), +`R1_PRODUCTION_RECLAMATION=NOT_ADMITTED`, `I1C_PRODUCTION_GC=NOT_ADMITTED`, `GATE4_TERMINAL=NO`. The maintainer admitted this +slice on 2026-10-15 for the `test-support` feature only, without a production caller or a production deletion release. +With `test-support`, `PinnedDir` gains `remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` +(each with an observer form where it matters), `RemovalOutcome`, `RemovalStage`, `Observer` and `QUARANTINE_PREFIX`. +`PinnedDir::sync` (no deletion; `fsync` of the directory, not confirmed on Windows) is in every build. + +**The two designs this slice does not use.** (1) `fstatat` then `unlinkat(parent, name)` on Unix: POSIX has no +unlink-by-inode, so a swap of the last component between the two calls removes whatever has the name by then. +(2) `FILE_FLAG_DELETE_ON_CLOSE` on Windows: it is chosen when the handle is opened and cannot be taken back, so a +file opened that way before the authentication is deleted at close even if the authentication fails. + +**Unix: move aside, identify the open object, then unlink.** The authenticated file is held open with identity +`(dev, ino)`. A quarantine directory (`.h1q-<128 random bits>`, mode 0700, checked to be a directory of the effective +user without group or other access) is created in the pinned parent and held by descriptor, and +`renameat2(RENAME_NOREPLACE)` (macOS `renameatx_np(RENAME_EXCL)`) moves whatever the name is bound to at that instant +into it; nothing is ever overwritten. Only there is the object identified: it is looked at (a device node is not +opened), opened without following a link, and the identity of the open object is compared. If it is the authenticated +object it is unlinked from the quarantine directory, and the link count of the open object must have dropped by +one; otherwise it is moved back under its name (`Swapped { restored: true }`) or, if the name was taken meanwhile, +kept in the quarantine directory (`restored: false`), and in no case unlinked. A file system without +`RENAME_NOREPLACE` answers `Unsupported` before anything is touched (that answer is not exercised on the hosted +runners). A failure at any step after the move (an I/O error, a refused sync is separate) puts the object back first +(rollback). A directory is moved and removed the same way with `unlinkat(AT_REMOVEDIR)`; one that fills after the check +is moved back (`NotEmpty`). The parent is synced after the removal (`Removed(DirectoryDurability)`); a sync that fails is +an error although the object is gone, and a rerun converges (`NotFound`). + +**No exclusivity is assumed.** The mode and the random name keep out other users and accidental interference, not another +process of the same user, which can list the parent, find the directory and enter it. The design therefore holds when +the quarantine directory is manipulated at every point before the last identification (tests below), and its one +irreducible window is stated instead of hidden. *Residual:* no Unix primitive unlinks "this inode"; between the last +identification and the unlink, an actor that can write into the quarantine directory and wins that one-syscall race +can have its own regular file unlinked instead. The link count of the authenticated object then did not drop and the +removal reports `RemovalOutcome::Disturbed`; it cannot be undone. The actor gains nothing it could not do directly +(it can unlink its own file, and, being the same user, the live one). A directory in that window is never unlinked (the +unlink fails, the directory is put back). Hard links: removing one name leaves the other names (the link count is +exposed as `PinnedFile::link_count` for a caller that must refuse a linked file); the accounting tolerates a link made +in the window. + +**Leftovers and crashes.** A crash after the move, or an object that could not be put back, leaves a `.h1q-*` directory +holding the object as `x`. A sweep never removes it by itself. `remove_leftover_file(name, authenticate)` takes that +directory by its name (a name without the prefix is `InvalidName`), authenticates the file in it again and, only if +accepted, removes it like any other (an object that does not authenticate stays, an empty quarantine directory is +removed). Windows creates no quarantine directory (`Unsupported`). + +**Windows: authenticate on the handle, then a disposition on that handle.** The object is opened through the same +handle-relative lookup as 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 then `FileDispositionInfoEx` (delete, POSIX semantics) is set on that same handle. Failing +authentication, an error or dropping the handle deletes nothing; there is no fallback to the classic disposition (a Windows +or file system without POSIX delete semantics is `Unsupported`), and a read-only file is refused, not forced. The +disposition is the only new `unsafe` (`SetFileInformationByHandle` with one fixed structure, its layout pinned by a test; +no generic or safe wrapper over another class); the `process` feature of `rustix` (already locked) is added for +`geteuid`, and nothing else changes in `Cargo.lock`. + +**Proof (lib unit tests, so that the macOS and Windows evidence job runs the same cases).** Unix: the baseline runs the +naive check-then-`unlinkat` against the same swap and shows the live file destroyed, so the observer forces the race; a +swap of the last name, a directory, a symlink and a FIFO swapped in, a name taken again while the object is aside (kept +in the quarantine directory), a name taken after the authenticated file moved aside, a swap inside the quarantine +directory before the identification, renaming the root during the removal, a vanished name, a swapped or filled +directory; the last window is forced and `Disturbed` is the pinned answer, a directory in it is put back, a hard link +made in it leaves the object alive; the quarantine directory is mode 0700 and gone afterwards; an injected failure at +each stage after the move rolls back and leaves no quarantine directory, a failed sync is reported and a rerun converges; +a simulated crash (the observer panics) leaves the object in quarantine, a leftover removal refuses what does not +authenticate and finishes what does, and a foreign object left by a refused put-back is never removed by it. +Mutation-checked: skipping the identity comparison fails five of those cases, and skipping the link-count verification +fails the forced last window. Windows (hosted runner is the only oracle): the name of the authenticated file cannot be +renamed, deleted or replaced while the handle is held; 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; a read-only file is +refused. Everywhere: only the authenticated object goes; a refused, failing or wrong-identity authentication removes +nothing; links, directories and missing names are not removed as files; hard-linked names; an injected failure before +anything changed changes nothing. + +**Isolation.** `feature_isolation_removal_test` scans the sources: the modules that hold the primitives are declared +under the gate on every platform, no other file names them, and no other module named `removal` exists (with a scan that +proves it sees an ungated declaration and export). `feature_isolation_compile_test` proves it with the compiler: the +crate is built the way a dependent gets it (default features) and with `test-support`, and a small crate that calls a +removal method or imports `RemovalOutcome` fails with E0599 and E0432 against the first and compiles against the second, +while a control that names only `PinnedDir::open_root` compiles against both. The no-dependent and default-closure checks +of `feature_isolation_test` still decide who can request the feature. + +**Not claimed.** No caller exists and the sweep is not ported (H1b); the producer-equivalence invariants (H1c) and +stored-set reclamation (I1c) are not started; a device node swapped in is refused before it is opened, and cannot be +created by an unprivileged actor, but that is argued, not tested (it needs privilege); the `Unsupported` answers of +file systems without `RENAME_NOREPLACE` or POSIX delete are not exercised on the hosted runners; the Disturbed window is +bounded and detected, not closed. From b60dc60310b9dac21e4d445c93a59d11aa54e666 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:39:43 +0200 Subject: [PATCH 02/13] refactor(core): cut the bound removal down to its API and its isolation (#445) The first draft of the removal was one pull request of 2,172 meaningful lines, over the 1,200-line hard-advisory tier. This commit keeps what has to land first: the API behind test-support, the proofs that it exists in no other build (a scan of the sources and a compile of a dependent with and without the feature), PinnedDir::sync, and the contract with the limit no Unix primitive can remove. Every removal answers Unsupported here; the Unix file removal, the Unix directory removal with the leftovers, and the Windows delete follow as stacked pull requests, each with its own tests. --- CHANGELOG.md | 2 +- crates/worldscript-secure-storage/Cargo.toml | 5 +- .../src/pinned/removal.rs | 72 +- .../src/pinned/tests/mod.rs | 4 - .../src/pinned/tests/remove.rs | 201 +----- .../src/pinned/tests/remove_unix.rs | 613 ------------------ .../src/pinned/tests/remove_windows.rs | 212 ------ .../src/pinned/unix/removal.rs | 370 +---------- .../src/pinned/windows/removal.rs | 108 +-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 131 ++-- 10 files changed, 127 insertions(+), 1591 deletions(-) delete mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs delete mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6854f39d2..22707cfaf 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 bound to the authenticated object, `test-support` only (slice H1a-2). `PinnedDir::remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` have no caller and exist only with the test-only feature (proved by a scan of the sources and by compiling a dependent with and without the feature). Unix moves an object into a quarantine directory with `RENAME_NOREPLACE`, identifies the open object there and only then unlinks it, checking the link count afterwards; a swap of the last name or a manipulation inside the quarantine directory is moved back, never unlinked, and the one window no Unix primitive closes is forced in a test and reported as `Disturbed`. Windows opens the object through the handle-relative lookup with delete access and read-only sharing (no writer can change the authenticated bytes), then sets the POSIX delete disposition on that handle: no `FILE_FLAG_DELETE_ON_CLOSE`, no fallback. Leftovers of a crash are finished only after authenticating again; `PinnedDir::sync` is added. One `unsafe` call (the disposition) and the `rustix` `process` feature, no new package. The sweep is not ported and no production reclamation is admitted. Issue #359. PR #1068. +- **R-15 Gate 4:** the API and the isolation of the bound removal, `test-support` only (slice H1a-2, part 1 of 4). `PinnedDir::remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` with `RemovalOutcome`, `RemovalStage` and `Observer` exist only with the test-only feature, have no caller and, in this part, answer `Unsupported` and remove nothing; the platform implementations follow in three stacked parts. A scan of the sources and a compile of a dependent with and without the feature keep the primitives out of every other build. `PinnedDir::sync` (no deletion) is added. The contract states what no Unix primitive can do (unlink "this inode": a same-user actor in the quarantine directory can win one race, which is detected after the fact as `Disturbed`, not prevented) and leaves the decision about that residual to the maintainer before any sweep uses it. No new `unsafe`, no new dependency feature, no new package. Issue #359. PR #1068. - **R-15 Gate 4:** handle-relative pinned I/O (slice H1a-1). A new parallel module `pinned` opens one directory with no-follow semantics, resolves it once to a link-free path, and reaches everything below it through a directory that is held open (`PinnedDir`, `PinnedFile`): bounded listing, bounded reading from the start of the file, identity and hard-link count; a link, junction or reparse point is refused at every component below the root, and a name must be exactly one ordinary component (on Windows also no stream, wildcard or device name). Unix uses `*at` calls on directory descriptors and checks the root's path without following a link; Windows pins a directory with a handle that does not share delete and opens children (`NtCreateFile` relative to the handle), listings (the handle's directory information) and entry kinds relative to it, so a directory converted into a reparse point in place leads nowhere. It changes nothing in the file system, has no caller and does not change `DurableFs`; the removal primitives follow in H1a-2 behind `test-support`. Local `unsafe` for the relative open, the listing and the final path, and one typed handle-query helper in `root_lock` (an `unsafe trait` with two implementations); the `Wdk_*` features of the `windows-sys` already locked, no new package. Issue #359. PR #1067. diff --git a/crates/worldscript-secure-storage/Cargo.toml b/crates/worldscript-secure-storage/Cargo.toml index a8cff79d3..aeed20b6a 100644 --- a/crates/worldscript-secure-storage/Cargo.toml +++ b/crates/worldscript-secure-storage/Cargo.toml @@ -35,11 +35,10 @@ zeroize_derive = "~1.4" # Gate 4 slice 4A (ยง11.1): the cross-process `root_commit_mutex` is an operating-system advisory # lock. rustix's safe `flock` on Unix (1.x, MSRV 1.63, already in the lockfile); on Windows one # `LockFileEx` call through windows-sys 0.52 (MSRV 1.56, already in the lockfile). -# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls (and, with `process`, `geteuid` for the -# quarantine directory of the test-only removal); on Windows the relative `NtCreateFile` open +# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls; on Windows the relative `NtCreateFile` open # (the `Wdk_*` features of windows-sys, no new package) and a few more calls. [target.'cfg(unix)'.dependencies] -rustix = { version = "1.1", default-features = false, features = ["fs", "process", "std"] } +rustix = { version = "1.1", default-features = false, features = ["fs", "std"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Wdk_Foundation", "Wdk_Storage_FileSystem"] } diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index df254eb54..14299abaf 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -1,40 +1,36 @@ -//! Gate 4 slice H1a-2, test-support only: removal that is bound to the object that was authenticated. +//! Gate 4 slice H1a-2, test-support only: the API of a removal that is bound to the object that was +//! authenticated, and the limits of what the platforms can guarantee, stated where they apply. //! //! **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. //! -//! **The two designs this replaces, and why.** Removing "the entry that was checked" with +//! **Status of this slice.** The API and its isolation. Every call answers [`PinnedError::Unsupported`] +//! until the platform implementations land in the successor slices (Unix files; Unix directories and +//! leftovers; Windows), so nothing is removed and nothing claims a safety it does not have yet. +//! +//! **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 //! has no unlink-by-inode, so the call removes whatever is bound to the name at that instant, which may //! be a live object another actor put there after the check. `FILE_FLAG_DELETE_ON_CLOSE` is chosen when //! the handle is opened and cannot be taken back, so a file opened with it before the authentication is //! deleted when the handle closes even if the authentication fails. //! -//! **Unix: move aside, identify, then unlink.** The authenticated file is held open with identity `I`. -//! A quarantine directory (a random name, mode 0700, owned by the process; this is a convenience, not -//! exclusivity: another process of the same user can find and enter it) is created in the pinned parent -//! and held by descriptor, and `renameat2(RENAME_NOREPLACE)` moves whatever the name is bound to *right -//! now* into it, atomically. Only there is the object identified, by opening it and comparing the -//! identity of the open object. If it is the authenticated object it is unlinked from the quarantine -//! directory, and the link count of the open object proves afterwards that this very object lost a name; -//! if it is anything else (a live file, a link, a directory) it is moved back under its name, or left -//! in the quarantine directory if the name was taken meanwhile, and in no case unlinked. -//! -//! *Residual, stated plainly.* No Unix primitive unlinks "this inode": `unlinkat` removes the name's -//! current binding. Between the last identification and the unlink, an actor that can write into the -//! quarantine directory (same user; it has to find the random name) and wins that one-syscall race -//! can have its own regular file unlinked instead. That is detected after the fact (the link count of -//! the authenticated object did not drop) and reported as [`RemovalOutcome::Disturbed`]; it cannot be -//! undone, and it gives that actor nothing it could not do directly (it can unlink its own file and, -//! being the same user, the live one). The observer forces this window in a test, with the outcome pinned. +//! **What the platform slices implement.** Unix: the entry is moved into a private quarantine +//! directory with `renameat2(RENAME_NOREPLACE)`, identified there by the open object, and only then +//! unlinked; anything that is not the authenticated object is put back, and every failure after the +//! move rolls back or says it could not ([`RemovalOutcome::Stranded`]). Windows: the object is opened +//! through the handle-relative lookup with delete access and read-only sharing, authenticated on that +//! handle, and then given the POSIX delete disposition on the same handle. //! -//! **Windows: authenticate on the handle, then a disposition on that handle.** The object is opened -//! through the same handle-relative lookup as every child, with delete access and a share mode of read -//! only: no other handle can write to it, rename it or delete it while this one is held (a writer that -//! already has it open makes the open fail with [`PinnedError::Busy`]), so the bytes that were -//! authenticated are the bytes that are deleted. Only then `FileDispositionInfoEx` (delete, POSIX -//! semantics) is set on that handle. Failing authentication, an error or dropping the handle deletes -//! nothing; there is no fallback to the classic disposition. +//! **The Unix window that stays open, and who decides about it.** No Unix primitive unlinks "this +//! inode": `unlinkat` removes the name's current binding. The quarantine directory is not exclusive (its +//! mode and random name keep out other users and accidents, not another process of the same user), so +//! between the last identification and the unlink an actor that can write into it and wins that one +//! race can have its own object removed instead. That is **detected after the fact, not prevented** +//! ([`RemovalOutcome::Disturbed`]), and the detection is a heuristic. That the same user could delete +//! the object directly is a statement about a threat model, not a guarantee of identity; whether this +//! residual is acceptable for a production sweep is a decision of the maintainer, to be taken before any +//! sweep uses these primitives. //! //! 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. @@ -53,8 +49,9 @@ pub const QUARANTINE_PREFIX: &str = ".h1q-"; /// What a removal did. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemovalOutcome { - /// The authenticated object, and only it, lost this name; the durability of the directory entry - /// change as far as the platform can confirm it. + /// The authenticated object lost this name (on Unix as far as the check after the unlink can tell, + /// see [`Disturbed`](Self::Disturbed)); the durability of the directory entry change as far as the + /// platform can confirm it. Removed(DirectoryDurability), /// The authentication said no; nothing was changed. Refused, @@ -66,10 +63,16 @@ pub enum RemovalOutcome { /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine /// directory (`.h1q-*`, never removed by a sweep). Swapped { restored: bool }, - /// Unix only: the unlink removed a name of an object other than the authenticated one (see the - /// residual in the module documentation). Nothing can be undone; the caller must treat the - /// directory as tampered with. + /// Unix only: after the unlink the authenticated object had not lost a name, so the name was bound + /// to another object when it was unlinked (see the window in the module documentation). That + /// object is gone and nothing can be undone: the caller must treat the directory as tampered with, + /// and never count this as a success. Disturbed, + /// Unix only: an error happened after the entry was moved into the quarantine directory and it + /// could not be put back (`cause` is the error that started it). The entry is in the quarantine + /// directory (`.h1q-*`, as `x`), found with a listing; [`PinnedDir::remove_leftover_file`] takes it + /// from there after authenticating it again. Nothing was removed. + Stranded { cause: PinnedError }, } /// The points of a removal at which a test can act, to force the exact interleavings the primitives are @@ -85,13 +88,14 @@ pub enum RemovalStage<'a> { /// Unix only: the object in the quarantine directory was identified as the authenticated one and the /// unlink is next. This is the last window. Identified { quarantined: &'a Path }, - /// Unix only: the object is gone and the directory is about to be synced. + /// Unix only: the directory changed (an object was removed, or moved and put back) and is about to + /// be synced. BeforeSync, } /// Called at each [`RemovalStage`]. An error stops the removal there: before the object was moved -/// nothing has happened, after it the object is put back (a rollback); after the removal it is reported -/// and a rerun converges. +/// nothing has happened, after it the object is put back (a rollback, or [`RemovalOutcome::Stranded`]); +/// at `BeforeSync` the change was made and the caller repeats [`PinnedDir::sync`]. pub type Observer<'a> = &'a mut dyn FnMut(RemovalStage<'_>) -> Result<(), PinnedError>; impl PinnedDir { diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index 1acddc1a7..b44352bcf 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -8,10 +8,6 @@ use std::sync::atomic::{AtomicU32, Ordering}; mod read; #[cfg(feature = "test-support")] 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 e782aaabe..1094c9c69 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs @@ -1,200 +1,29 @@ -//! Removal on every platform: only the authenticated object goes, and a refusal changes nothing. +//! Removal, in the slice that only brings the API and the isolation: every call refuses, so nothing is +//! removed and nothing claims a safety it does not have yet. use std::fs; -use super::{link_to_dir, Scratch}; -use crate::durable::DirectoryDurability; -use crate::pinned::{PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage}; - -/// Accepts a file whose whole content is `expected`. -pub(super) fn holds( - expected: &'static [u8], -) -> impl FnOnce(&mut PinnedFile) -> Result { - move |file| Ok(file.read_at_most(64)? == expected) -} - -/// What a completed removal reports on this platform. -pub(super) fn removed() -> Result { - let durability = if cfg!(unix) { - DirectoryDurability::Confirmed - } else { - DirectoryDurability::NotConfirmed - }; - Ok(RemovalOutcome::Removed(durability)) -} - -pub(super) fn names(pinned: &PinnedDir) -> Vec { - let mut listed: Vec = pinned - .entries(32) - .unwrap() - .into_iter() - .map(|name| name.to_string_lossy().into_owned()) - .collect(); - listed.sort(); - listed -} +use super::Scratch; +use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome}; #[test] -fn an_authenticated_file_is_removed_and_nothing_else_is() { - let scratch = Scratch::new("remove"); +fn every_removal_refuses_until_a_platform_implements_it_and_removes_nothing() { + let scratch = Scratch::new("remove-stub"); 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 = pinned.remove_authenticated_file("a", holds(b"alpha")); - assert_eq!( - (outcome, names(&pinned), fs::read(root.join("b")).unwrap()), - (removed(), vec!["b".to_string()], b"beta".to_vec()) - ); -} - -#[test] -fn a_refused_authentication_a_failing_one_and_a_wrong_identity_remove_nothing() { - let scratch = Scratch::new("refuse"); - let root = scratch.root(); - fs::write(root.join("a"), b"alpha").unwrap(); - fs::write(root.join("other"), b"alpha").unwrap(); + fs::create_dir(root.join("d")).unwrap(); let pinned = PinnedDir::open_root(&root).unwrap(); - let expected = pinned.open_file("other").unwrap().identity(); - let refused = pinned.remove_authenticated_file("a", holds(b"something else")); - let failed = pinned.remove_authenticated_file("a", |_| Err(PinnedError::Busy)); - // The bytes match, but this is not the object the caller meant. - let wrong = pinned.remove_authenticated_file("a", move |file| Ok(file.identity() == expected)); + 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!( ( - refused, - failed, - wrong, + refusals, fs::read(root.join("a")).unwrap(), - names(&pinned) + root.join("d").is_dir() ), - ( - Ok(RemovalOutcome::Refused), - Err(PinnedError::Busy), - Ok(RemovalOutcome::Refused), - b"alpha".to_vec(), - vec!["a".to_string(), "other".to_string()] - ) - ); -} - -#[test] -fn a_link_a_directory_and_a_missing_name_are_not_removed_as_files() { - let scratch = Scratch::new("not-files"); - let root = scratch.root(); - let aside = scratch.aside(); - fs::create_dir(aside.join("target")).unwrap(); - fs::write(aside.join("target").join("kept"), b"kept").unwrap(); - link_to_dir(&aside.join("target"), &root.join("link")); - fs::create_dir(root.join("dir")).unwrap(); - let pinned = PinnedDir::open_root(&root).unwrap(); - let remove = |name: &str| pinned.remove_authenticated_file(name, |_| Ok(true)); - assert_eq!( - ( - remove("link"), - remove("dir"), - remove("missing"), - fs::read(aside.join("target").join("kept")).unwrap(), - names(&pinned) - ), - ( - Err(PinnedError::Redirected), - Err(PinnedError::NotRegularFile), - Err(PinnedError::NotFound), - b"kept".to_vec(), - vec!["dir".to_string(), "link".to_string()] - ) - ); -} - -#[test] -fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() { - let scratch = Scratch::new("dirs"); - let root = scratch.root(); - let aside = scratch.aside(); - fs::create_dir(root.join("empty")).unwrap(); - fs::create_dir(root.join("filled")).unwrap(); - fs::write(root.join("filled").join("inside"), b"inside").unwrap(); - fs::create_dir(aside.join("target")).unwrap(); - link_to_dir(&aside.join("target"), &root.join("link")); - let pinned = PinnedDir::open_root(&root).unwrap(); - assert_eq!( - ( - pinned.remove_empty_dir("empty"), - pinned.remove_empty_dir("filled"), - pinned.remove_empty_dir("link"), - pinned.remove_empty_dir("missing"), - fs::read(root.join("filled").join("inside")).unwrap(), - aside.join("target").is_dir(), - names(&pinned) - ), - ( - removed(), - Ok(RemovalOutcome::NotEmpty), - Err(PinnedError::Redirected), - Err(PinnedError::NotFound), - b"inside".to_vec(), - true, - vec!["filled".to_string(), "link".to_string()] - ) - ); -} - -#[test] -fn removing_a_hard_linked_name_leaves_the_other_names() { - let scratch = Scratch::new("hard-link"); - let root = scratch.root(); - fs::write(root.join("a"), b"alpha").unwrap(); - fs::hard_link(root.join("a"), root.join("a-too")).unwrap(); - let pinned = PinnedDir::open_root(&root).unwrap(); - let links = pinned.open_file("a").unwrap().link_count(); - let outcome = pinned.remove_authenticated_file("a", holds(b"alpha")); - assert_eq!( - ( - links, - outcome, - names(&pinned), - fs::read(root.join("a-too")).unwrap(), - pinned.open_file("a-too").unwrap().link_count() - ), - ( - Ok(2), - removed(), - vec!["a-too".to_string()], - b"alpha".to_vec(), - Ok(1) - ) - ); -} - -#[test] -fn a_failure_injected_before_anything_changed_changes_nothing() { - let scratch = Scratch::new("inject-checked"); - 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 denied = || Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)); - let file = pinned.remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - if stage == RemovalStage::Checked { - return denied(); - } - Ok(()) - }); - let dir = pinned.remove_empty_dir_observed("d", &mut |stage| { - if stage == RemovalStage::Checked { - return denied(); - } - Ok(()) - }); - let expected = Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)); - assert_eq!( - (file, dir, names(&pinned), fs::read(root.join("a")).unwrap()), - ( - expected, - expected, - vec!["a".to_string(), "d".to_string()], - b"alpha".to_vec() - ) + ([Err(PinnedError::Unsupported); 3], b"alpha".to_vec(), true) ); } diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs deleted file mode 100644 index 378d1cc6c..000000000 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! Unix removal under the swap the design exists for: another actor replaces the last component of the -//! name, or the object inside the quarantine directory, between the steps of the removal. Each case -//! forces the swap through the observer at a chosen stage and pins what happens to every object. The -//! counter-proofs show that the swap really takes place (the baseline, and the asserted state of the -//! displaced objects). - -use std::fs; -use std::os::fd::AsFd; -use std::os::unix::fs::PermissionsExt; -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::path::{Path, PathBuf}; - -use rustix::fs::{unlinkat, AtFlags}; - -use super::remove::{holds, names, removed}; -use super::Scratch; -use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; - -/// A pinned directory holding `a` = "alpha", with the paths a swap needs. -struct Fixture { - /// Keeps the directories until the test ends. - _scratch: Scratch, - root: PathBuf, - aside: PathBuf, - pinned: PinnedDir, -} - -fn fixture(label: &str) -> Fixture { - let scratch = Scratch::new(label); - let root = scratch.root(); - let aside = scratch.aside(); - fs::write(root.join("a"), b"alpha").unwrap(); - let pinned = PinnedDir::open_root(&root).unwrap(); - Fixture { - _scratch: scratch, - root, - aside, - pinned, - } -} - -/// The stages of a removal, without their payload. -#[derive(Clone, Copy, PartialEq)] -enum At { - Checked, - MovedAside, - Identified, - BeforeSync, -} - -/// An observer that runs `action` (with the path inside the quarantine directory, once there is one) at -/// the stage `at` and otherwise does nothing. -fn on( - at: At, - mut action: impl FnMut(Option<&Path>), -) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { - move |stage| { - let (here, path) = match stage { - RemovalStage::Checked => (At::Checked, None), - RemovalStage::MovedAside { quarantined } => (At::MovedAside, Some(quarantined)), - RemovalStage::Identified { quarantined } => (At::Identified, Some(quarantined)), - RemovalStage::BeforeSync => (At::BeforeSync, None), - }; - if here == at { - action(path); - } - Ok(()) - } -} - -/// An observer that fails at the stage `at`. -fn failing_at(at: At) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { - move |stage| { - let here = match stage { - RemovalStage::Checked => At::Checked, - RemovalStage::MovedAside { .. } => At::MovedAside, - RemovalStage::Identified { .. } => At::Identified, - RemovalStage::BeforeSync => At::BeforeSync, - }; - if here == at { - return Err(denied()); - } - Ok(()) - } -} - -fn denied() -> PinnedError { - PinnedError::Io(std::io::ErrorKind::PermissionDenied) -} - -/// The last-component swap: the original is renamed out of the way and a live file takes its name. -fn swap_in_a_live_file(root: &Path, aside: &Path) { - fs::rename(root.join("a"), aside.join("original")).unwrap(); - fs::write(root.join("a"), b"live").unwrap(); -} - -/// The quarantine directories a removal left in `root`. -fn quarantines(root: &Path) -> Vec { - let mut found: Vec = fs::read_dir(root) - .unwrap() - .map(|entry| entry.unwrap().path()) - .filter(|path| { - path.file_name() - .unwrap() - .to_string_lossy() - .starts_with(QUARANTINE_PREFIX) - }) - .collect(); - found.sort(); - found -} - -/// The content of the object left in the only quarantine directory of `root`. -fn held_in_quarantine(root: &Path) -> Option> { - let all = quarantines(root); - assert!(all.len() <= 1, "{all:?}"); - fs::read(all.first()?.join(super::super::unix::removal::INSIDE)).ok() -} - -/// A name-based removal: identify the entry, let `swap` run, then unlink the name. -fn naive_removal( - pinned: &PinnedDir, - name: &str, - swap: impl FnOnce(), -) -> Result<(), rustix::io::Errno> { - let authenticated = pinned.open_file(name).unwrap(); - assert_eq!( - pinned.entry_kind(name), - Ok(Some(crate::durable::EntryKind::File)) - ); - swap(); - drop(authenticated); - unlinkat(pinned.handle.as_fd(), name, AtFlags::empty()) -} - -#[test] -fn the_swap_destroys_the_live_file_of_a_name_based_removal_so_the_hook_forces_the_race() { - let f = fixture("baseline"); - let result = naive_removal(&f.pinned, "a", || swap_in_a_live_file(&f.root, &f.aside)); - assert_eq!( - ( - result, - f.root.join("a").exists(), - fs::read(f.aside.join("original")).unwrap() - ), - (Ok(()), false, b"alpha".to_vec()) - ); -} - -#[test] -fn a_swap_of_the_last_name_after_the_check_destroys_nothing() { - let f = fixture("swap"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)), - ); - assert_eq!( - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - fs::read(f.aside.join("original")).unwrap(), - names(&f.pinned) - ), - ( - Ok(RemovalOutcome::Swapped { restored: true }), - b"live".to_vec(), - b"alpha".to_vec(), - vec!["a".to_string()] - ) - ); -} - -/// A swap, and whether what it put at the name is still there afterwards. -type Swap = (&'static str, fn(&Path, &Path), fn(&Path, &Path) -> bool); - -#[test] -fn a_directory_a_link_or_a_fifo_swapped_in_is_put_back_untouched() { - use std::os::unix::fs::FileTypeExt; - - let swaps: [Swap; 3] = [ - ( - "directory", - |root, aside| { - fs::rename(root.join("a"), aside.join("original")).unwrap(); - fs::create_dir(root.join("a")).unwrap(); - fs::write(root.join("a").join("inside"), b"inside").unwrap(); - }, - |root, _| fs::read(root.join("a").join("inside")).ok() == Some(b"inside".to_vec()), - ), - ( - "symlink", - |root, aside| { - fs::rename(root.join("a"), aside.join("original")).unwrap(); - fs::write(aside.join("target"), b"target").unwrap(); - std::os::unix::fs::symlink(aside.join("target"), root.join("a")).unwrap(); - }, - |root, aside| { - fs::read_link(root.join("a")).ok() == Some(aside.join("target")) - && fs::read(aside.join("target")).ok() == Some(b"target".to_vec()) - }, - ), - ( - "fifo", - |root, aside| { - fs::rename(root.join("a"), aside.join("original")).unwrap(); - let made = std::process::Command::new("mkfifo") - .arg(root.join("a")) - .status() - .unwrap(); - assert!(made.success()); - }, - |root, _| fs::symlink_metadata(root.join("a")).is_ok_and(|m| m.file_type().is_fifo()), - ), - ]; - let outcomes: Vec<_> = swaps - .into_iter() - .map(|(label, swap, intact)| { - let f = fixture(label); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| swap(&f.root, &f.aside)), - ); - ( - label, - outcome, - intact(&f.root, &f.aside), - fs::read(f.aside.join("original")).unwrap(), - ) - }) - .collect(); - let put_back = |label| { - ( - label, - Ok(RemovalOutcome::Swapped { restored: true }), - true, - b"alpha".to_vec(), - ) - }; - assert_eq!( - outcomes, - [put_back("directory"), put_back("symlink"), put_back("fifo")] - ); -} - -#[test] -fn a_name_taken_again_while_the_swapped_file_is_aside_leaves_every_object_intact() { - let f = fixture("retaken"); - let mut at_checked = on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)); - let mut at_moved = on(At::MovedAside, |_| { - fs::write(f.root.join("a"), b"retaken").unwrap() - }); - let outcome = f - .pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - at_checked(stage)?; - at_moved(stage) - }); - assert_eq!( - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - fs::read(f.aside.join("original")).unwrap(), - held_in_quarantine(&f.root) - ), - ( - Ok(RemovalOutcome::Swapped { restored: false }), - b"retaken".to_vec(), - b"alpha".to_vec(), - Some(b"live".to_vec()) - ) - ); -} - -#[test] -fn a_swap_inside_the_quarantine_directory_before_the_identification_is_never_unlinked() { - let f = fixture("inside"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::MovedAside, |quarantined| { - let quarantined = quarantined.unwrap(); - fs::rename(quarantined, f.aside.join("moved-original")).unwrap(); - fs::write(quarantined, b"foreign").unwrap(); - }), - ); - assert_eq!( - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - fs::read(f.aside.join("moved-original")).unwrap(), - names(&f.pinned) - ), - ( - Ok(RemovalOutcome::Swapped { restored: true }), - b"foreign".to_vec(), - b"alpha".to_vec(), - vec!["a".to_string()] - ) - ); -} - -#[test] -fn the_last_window_is_forced_and_a_foreign_file_unlinked_in_it_is_reported() { - // No Unix primitive unlinks "this inode": after the last identification the unlink removes whatever - // the name is bound to. A writer of the quarantine directory that wins this one race loses nothing - // it could not destroy itself, and the removal says so instead of reporting success. - let f = fixture("window"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Identified, |quarantined| { - let quarantined = quarantined.unwrap(); - fs::rename(quarantined, f.aside.join("original")).unwrap(); - fs::write(quarantined, b"foreign").unwrap(); - }), - ); - assert_eq!( - ( - outcome, - f.root.join("a").exists(), - fs::read(f.aside.join("original")).unwrap(), - quarantines(&f.root).len() - ), - (Ok(RemovalOutcome::Disturbed), false, b"alpha".to_vec(), 0) - ); -} - -#[test] -fn a_directory_in_the_last_window_is_never_unlinked_and_goes_back() { - let f = fixture("window-dir"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Identified, |quarantined| { - let quarantined = quarantined.unwrap(); - fs::rename(quarantined, f.aside.join("original")).unwrap(); - fs::create_dir(quarantined).unwrap(); - fs::write(quarantined.join("inside"), b"inside").unwrap(); - }), - ); - assert_eq!( - ( - outcome.is_err(), - fs::read(f.root.join("a").join("inside")).unwrap(), - fs::read(f.aside.join("original")).unwrap(), - quarantines(&f.root).len() - ), - (true, b"inside".to_vec(), b"alpha".to_vec(), 0) - ); -} - -#[test] -fn a_hard_link_made_in_the_last_window_leaves_the_object_alive_under_that_name() { - let f = fixture("window-link"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Identified, |quarantined| { - fs::hard_link(quarantined.unwrap(), f.aside.join("extra")).unwrap(); - }), - ); - assert_eq!( - ( - outcome, - names(&f.pinned), - fs::read(f.aside.join("extra")).unwrap() - ), - (removed(), Vec::::new(), b"alpha".to_vec()) - ); -} - -#[test] -fn a_name_taken_after_the_authenticated_file_moved_aside_keeps_the_new_file() { - let f = fixture("taken-after"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::MovedAside, |_| { - fs::write(f.root.join("a"), b"new").unwrap() - }), - ); - assert_eq!( - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - names(&f.pinned) - ), - (removed(), b"new".to_vec(), vec!["a".to_string()]) - ); -} - -#[test] -fn a_name_that_vanishes_after_the_check_is_reported_and_leaves_nothing_behind() { - let f = fixture("vanish"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| fs::remove_file(f.root.join("a")).unwrap()), - ); - assert_eq!( - (outcome, names(&f.pinned)), - (Ok(RemovalOutcome::Vanished), Vec::::new()) - ); -} - -#[test] -fn renaming_the_root_during_the_removal_does_not_redirect_it() { - let f = fixture("root-renamed"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| { - fs::rename(&f.root, f.aside.join("moved-root")).unwrap(); - fs::create_dir(&f.root).unwrap(); - fs::write(f.root.join("a"), b"decoy").unwrap(); - }), - ); - let moved = fs::read_dir(f.aside.join("moved-root")).unwrap().count(); - assert_eq!( - (outcome, fs::read(f.root.join("a")).unwrap(), moved), - (removed(), b"decoy".to_vec(), 0) - ); -} - -#[test] -fn a_directory_swapped_or_filled_after_the_check_is_put_back_and_not_removed() { - let f = fixture("dir-swap"); - fs::create_dir(f.root.join("d")).unwrap(); - fs::create_dir(f.root.join("e")).unwrap(); - let swapped = f.pinned.remove_empty_dir_observed( - "d", - &mut on(At::Checked, |_| { - fs::rename(f.root.join("d"), f.aside.join("original-d")).unwrap(); - fs::create_dir(f.root.join("d")).unwrap(); - fs::write(f.root.join("d").join("live"), b"live").unwrap(); - }), - ); - let filled = f.pinned.remove_empty_dir_observed( - "e", - &mut on(At::Checked, |_| { - fs::write(f.root.join("e").join("late"), b"late").unwrap() - }), - ); - assert_eq!( - ( - swapped, - fs::read(f.root.join("d").join("live")).unwrap(), - f.aside.join("original-d").is_dir(), - filled, - fs::read(f.root.join("e").join("late")).unwrap(), - names(&f.pinned) - ), - ( - Ok(RemovalOutcome::Swapped { restored: true }), - b"live".to_vec(), - true, - Ok(RemovalOutcome::NotEmpty), - b"late".to_vec(), - vec!["a".to_string(), "d".to_string(), "e".to_string()] - ) - ); -} - -#[test] -fn the_quarantine_directory_is_closed_to_other_users_and_gone_afterwards() { - let f = fixture("private"); - let mut seen = None; - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::MovedAside, |quarantined| { - let directory = quarantined.unwrap().parent().unwrap(); - seen = Some(( - directory - .file_name() - .unwrap() - .to_string_lossy() - .starts_with(QUARANTINE_PREFIX), - fs::metadata(directory).unwrap().permissions().mode() & 0o777, - )); - }), - ); - assert_eq!( - (outcome, seen, quarantines(&f.root).len()), - (removed(), Some((true, 0o700)), 0) - ); -} - -#[test] -fn a_failure_after_the_move_puts_the_object_back_and_leaves_no_quarantine_directory() { - let outcomes: Vec<_> = [At::MovedAside, At::Identified] - .into_iter() - .map(|at| { - let f = fixture("rollback"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut failing_at(at), - ); - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - quarantines(&f.root).len(), - ) - }) - .collect(); - assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 2]); -} - -#[test] -fn a_failed_sync_is_reported_although_the_object_is_gone_and_a_rerun_converges() { - let f = fixture("sync"); - let first = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut failing_at(At::BeforeSync), - ); - let rerun = f.pinned.remove_authenticated_file("a", holds(b"alpha")); - assert_eq!( - (first, rerun, names(&f.pinned)), - ( - Err(denied()), - Err(PinnedError::NotFound), - Vec::::new() - ) - ); -} - -/// A removal that "crashes" (the observer panics) once the object is in the quarantine directory. -fn crash_after_the_move(f: &Fixture) { - let crashed = catch_unwind(AssertUnwindSafe(|| { - let _ = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::MovedAside, |_| panic!("simulated crash")), - ); - })); - assert!(crashed.is_err()); -} - -#[test] -fn a_crash_leaves_the_object_in_quarantine_and_a_leftover_removal_finishes_it_after_authenticating() -{ - let f = fixture("crash"); - crash_after_the_move(&f); - let left = quarantines(&f.root); - let name = left[0].file_name().unwrap().to_owned(); - let before = (names(&f.pinned).len(), held_in_quarantine(&f.root)); - let refused = f - .pinned - .remove_leftover_file(&name, holds(b"something else")); - let finished = f.pinned.remove_leftover_file(&name, holds(b"alpha")); - assert_eq!( - (before, refused, finished, names(&f.pinned)), - ( - (1, Some(b"alpha".to_vec())), - Ok(RemovalOutcome::Refused), - removed(), - Vec::::new() - ) - ); -} - -#[test] -fn a_leftover_is_taken_by_its_name_only_and_an_empty_one_is_removed() { - let f = fixture("leftover-names"); - fs::create_dir(f.root.join(".h1q-empty")).unwrap(); - fs::set_permissions(f.root.join(".h1q-empty"), fs::Permissions::from_mode(0o700)).unwrap(); - fs::create_dir(f.root.join("not-a-quarantine")).unwrap(); - let taken = |name: &str| f.pinned.remove_leftover_file(name, |_| Ok(true)); - assert_eq!( - ( - taken("a"), - taken("not-a-quarantine"), - taken(".h1q-empty"), - names(&f.pinned) - ), - ( - Err(PinnedError::InvalidName), - Err(PinnedError::InvalidName), - Ok(RemovalOutcome::Vanished), - vec!["a".to_string(), "not-a-quarantine".to_string()] - ) - ); -} - -#[test] -fn a_foreign_object_left_in_quarantine_is_never_removed_by_a_leftover_removal() { - let f = fixture("leftover-foreign"); - let mut at_checked = on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)); - let mut at_moved = on(At::MovedAside, |_| { - fs::write(f.root.join("a"), b"retaken").unwrap() - }); - let first = f - .pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - at_checked(stage)?; - at_moved(stage) - }); - let name = quarantines(&f.root)[0].file_name().unwrap().to_owned(); - // The object in the quarantine directory is the live file, not an object of the sweep. - let refused = f.pinned.remove_leftover_file(&name, holds(b"alpha")); - assert_eq!( - (first, refused, held_in_quarantine(&f.root)), - ( - Ok(RemovalOutcome::Swapped { restored: false }), - Ok(RemovalOutcome::Refused), - Some(b"live".to_vec()) - ) - ); -} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs deleted file mode 100644 index 3f30374e1..000000000 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_windows.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! 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/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 3c2f616af..67be724e9 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -1,365 +1,33 @@ -//! Unix removal, test-support only: move the object into a quarantine directory, identify the open -//! object there, and only then unlink it. See the parent `removal` module for the reasoning and for the -//! one residual that no Unix primitive removes. +//! Unix removal, test-support only: not implemented in this slice. Every call refuses with +//! `Unsupported`; the successor slices bring the quarantine-based removal of files, then of directories +//! and leftovers. See the parent `removal` module for the contract. -use std::ffi::{OsStr, OsString}; -use std::fs::File; -use std::os::unix::ffi::OsStrExt; -use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; +use std::ffi::OsStr; -use rustix::fs::{mkdirat, openat, renameat_with, unlinkat, AtFlags, Mode, RenameFlags}; -use rustix::io::Errno; -use rustix::process::geteuid; - -use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; -use crate::durable::{DirectoryDurability, EntryKind}; -use crate::pinned::{ - ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, - QUARANTINE_PREFIX, -}; -use crate::random::{OsRandom, RandomSource}; - -/// The name an object has inside the quarantine directory. -pub(in crate::pinned) const INSIDE: &str = "x"; - -/// A quarantine directory inside the pinned parent, held by descriptor. Whatever it holds is outside -/// every name grammar of the store and is never removed by a sweep. -struct Quarantine { - fd: File, - name: OsString, -} - -/// What is bound to the quarantine name right now. -enum Inside { - Absent, - /// Not the kind that was asked for (a link, a directory instead of a file, a FIFO). - Foreign, - /// Opened, with the identity of the open object. - Object(File, ObjectIdentity), -} - -/// How a removal ended before the durability of the parent directory was settled. -enum Step { - Finished(RemovalOutcome), - Unlinked, -} - -impl Quarantine { - fn create(parent: &DirHandle) -> Result { - let mut bits = [0u8; 16]; - OsRandom - .fill(&mut bits) - .map_err(|_| PinnedError::Unsupported)?; - let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); - let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); - mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; - match Self::open_private(parent, &name) { - Ok(fd) => Ok(Quarantine { fd, name }), - Err(error) => { - let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); - Err(error) - } - } - } - - /// An existing quarantine directory of `parent`, found by its name. - fn open_existing(parent: &DirHandle, name: &OsStr) -> Result { - if !name.as_bytes().starts_with(QUARANTINE_PREFIX.as_bytes()) { - return Err(PinnedError::InvalidName); - } - let fd = Self::open_private(parent, name)?; - Ok(Quarantine { - fd, - name: name.to_os_string(), - }) - } - - /// The directory `name`, if it is a directory of this process's user that has no access for group - /// or others. That keeps out a directory of another user that was swapped in; it does not keep out - /// the same user, which the removal does not rely on. - fn open_private(parent: &DirHandle, name: &OsStr) -> Result { - let fd = File::from(openat(parent, name, dir_flags(), Mode::empty()).map_err(io_error)?); - let stat = rustix::fs::fstat(&fd).map_err(io_error)?; - let private = kind_of(&stat) == EntryKind::Directory - && stat.st_uid == geteuid().as_raw() - && widen(stat.st_mode)? & 0o077 == 0; - if private { - Ok(fd) - } else { - Err(PinnedError::Unsupported) - } - } - - /// Moves what `name` of `parent` is bound to right now into this directory; `false` if the name - /// leads nowhere. - fn adopt(&self, parent: &DirHandle, name: &OsStr) -> Result { - match renameat_with(parent, name, &self.fd, INSIDE, RenameFlags::NOREPLACE) { - Ok(()) => Ok(true), - Err(Errno::NOENT) => Ok(false), - // No RENAME_NOREPLACE here: nothing was moved, so nothing can be identified safely. - Err(Errno::INVAL | Errno::NOSYS | Errno::NOTSUP) => Err(PinnedError::Unsupported), - Err(errno) => Err(io_error(errno)), - } - } - - /// Looks at the entry before it is opened (opening a device node runs its driver), then opens it - /// without following a link and reports the identity of the open object. - fn inspect(&self, kind: EntryKind) -> Result { - let inside = OsStr::new(INSIDE); - match stat_child(&self.fd, inside)? { - None => return Ok(Inside::Absent), - Some(stat) if kind_of(&stat) != kind => return Ok(Inside::Foreign), - Some(_) => {} - } - let flags = if kind == EntryKind::Directory { - dir_flags() - } else { - file_flags() - }; - let object = match openat(&self.fd, inside, flags, Mode::empty()) { - Ok(fd) => File::from(fd), - Err(Errno::NOENT) => return Ok(Inside::Absent), - // Swapped between the look and the open. - Err(Errno::LOOP | Errno::NOTDIR | Errno::ISDIR | Errno::NXIO) => { - return Ok(Inside::Foreign) - } - Err(errno) => return Err(io_error(errno)), - }; - let metadata = object.metadata().map_err(PinnedError::from)?; - let right_kind = if kind == EntryKind::Directory { - metadata.is_dir() - } else { - metadata.is_file() - }; - if !right_kind { - return Ok(Inside::Foreign); - } - let identity = ObjectIdentity::new(metadata.dev(), u128::from(metadata.ino())); - Ok(Inside::Object(object, identity)) - } - - /// Moves the object back under `name`; `false` if the name is taken (or anything else prevents it), - /// in which case the object stays here. - fn restore(&self, parent: &DirHandle, name: &OsStr) -> bool { - renameat_with(&self.fd, INSIDE, parent, name, RenameFlags::NOREPLACE).is_ok() - } - - fn path_inside(&self, parent_path: &Path) -> PathBuf { - parent_path.join(&self.name).join(INSIDE) - } - - /// Removes this directory if it is empty. A directory that still holds an object is left. - fn discard(self, parent: &DirHandle) { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); - } -} - -/// Unlinks the object `object` that was identified in the quarantine directory. Afterwards the link -/// count of the open file must have dropped by one: if it did not, the name was bound to another -/// object when it was unlinked. -fn unlink_verified( - quarantine: &Quarantine, - object: &File, - kind: EntryKind, -) -> Result { - if kind == EntryKind::Directory { - return match unlinkat(&quarantine.fd, INSIDE, AtFlags::REMOVEDIR) { - Ok(()) => Ok(RemovalOutcome::Removed(DirectoryDurability::Confirmed)), - Err(Errno::NOTEMPTY | Errno::EXIST) => Ok(RemovalOutcome::NotEmpty), - Err(Errno::NOENT) => Ok(RemovalOutcome::Vanished), - Err(errno) => Err(io_error(errno)), - }; - } - let before = object.metadata().map_err(PinnedError::from)?.nlink(); - match unlinkat(&quarantine.fd, INSIDE, AtFlags::empty()) { - Ok(()) => {} - Err(Errno::NOENT) => return Ok(RemovalOutcome::Vanished), - Err(errno) => return Err(io_error(errno)), - } - let after = object.metadata().map_err(PinnedError::from)?.nlink(); - if after + 1 == before { - Ok(RemovalOutcome::Removed(DirectoryDurability::Confirmed)) - } else { - Ok(RemovalOutcome::Disturbed) - } -} +use crate::pinned::{Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome}; 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 { - let mut file = dir.open_file(name)?; - if !authenticate(&mut file)? { - return Ok(RemovalOutcome::Refused); - } - observer(RemovalStage::Checked)?; - remove_moved(dir, name, file.identity(), EntryKind::File, observer) + Err(PinnedError::Unsupported) } pub(in crate::pinned) fn remove_dir( - dir: &PinnedDir, - name: &OsStr, - observer: Observer<'_>, + _dir: &PinnedDir, + _name: &OsStr, + _observer: Observer<'_>, ) -> Result { - let child = dir.open_dir(name)?; - match child.entries(0) { - Ok(_) => {} - Err(PinnedError::TooManyEntries) => return Ok(RemovalOutcome::NotEmpty), - Err(error) => return Err(error), - } - observer(RemovalStage::Checked)?; - remove_moved(dir, name, child.identity(), EntryKind::Directory, observer) + Err(PinnedError::Unsupported) } -/// Removes the leftover file of the quarantine directory `quarantine` after authenticating it again. pub(in crate::pinned) fn remove_leftover( - dir: &PinnedDir, - quarantine: &OsStr, - authenticate: impl FnOnce(&mut PinnedFile) -> Result, - observer: Observer<'_>, -) -> Result { - let held = Quarantine::open_existing(&dir.handle, quarantine)?; - let step = leftover_step(&held, dir, authenticate, observer); - held.discard(&dir.handle); - settle(dir, step?, observer) -} - -fn leftover_step( - held: &Quarantine, - dir: &PinnedDir, - authenticate: impl FnOnce(&mut PinnedFile) -> Result, - observer: Observer<'_>, -) -> Result { - let (file, identity) = match held.inspect(EntryKind::File)? { - Inside::Absent => return Ok(Step::Finished(RemovalOutcome::Vanished)), - Inside::Foreign => return Ok(Step::Finished(RemovalOutcome::Refused)), - Inside::Object(file, identity) => (file, identity), - }; - let mut pinned = PinnedFile { file, identity }; - if !authenticate(&mut pinned)? { - return Ok(Step::Finished(RemovalOutcome::Refused)); - } - observer(RemovalStage::Identified { - quarantined: &held.path_inside(&dir.path), - })?; - unlinked(unlink_verified(held, &pinned.file, EntryKind::File)?) -} - -/// Removes the entry `name` of `dir` if, once moved aside, it is the object `expected` of kind `kind`. -fn remove_moved( - dir: &PinnedDir, - name: &OsStr, - expected: ObjectIdentity, - kind: EntryKind, - observer: Observer<'_>, -) -> Result { - let quarantine = Quarantine::create(&dir.handle)?; - let step = detach(&quarantine, dir, name, expected, kind, observer); - quarantine.discard(&dir.handle); - settle(dir, step?, observer) -} - -fn detach( - quarantine: &Quarantine, - dir: &PinnedDir, - name: &OsStr, - expected: ObjectIdentity, - kind: EntryKind, - observer: Observer<'_>, -) -> Result { - if !quarantine.adopt(&dir.handle, name)? { - return Ok(Step::Finished(RemovalOutcome::Vanished)); - } - let inside = quarantine.path_inside(&dir.path); - rolled_back( - quarantine, - dir, - name, - observer(RemovalStage::MovedAside { - quarantined: &inside, - }), - )?; - match quarantine.inspect(kind)? { - Inside::Absent => Ok(Step::Finished(RemovalOutcome::Vanished)), - Inside::Object(object, found) if found == expected => { - conclude(quarantine, dir, name, &object, kind, observer) - } - Inside::Object(..) | Inside::Foreign => { - let restored = quarantine.restore(&dir.handle, name); - Ok(Step::Finished(RemovalOutcome::Swapped { restored })) - } - } -} - -/// The last window: the object in the quarantine directory is the authenticated one. -fn conclude( - quarantine: &Quarantine, - dir: &PinnedDir, - name: &OsStr, - object: &File, - kind: EntryKind, - observer: Observer<'_>, -) -> Result { - let inside = quarantine.path_inside(&dir.path); - rolled_back( - quarantine, - dir, - name, - observer(RemovalStage::Identified { - quarantined: &inside, - }), - )?; - match unlink_verified(quarantine, object, kind) { - Ok(RemovalOutcome::NotEmpty) => { - // A directory that filled after the check goes back where it was. - quarantine.restore(&dir.handle, name); - Ok(Step::Finished(RemovalOutcome::NotEmpty)) - } - Ok(outcome) => unlinked(outcome), - Err(error) => { - quarantine.restore(&dir.handle, name); - Err(error) - } - } -} - -fn unlinked(outcome: RemovalOutcome) -> Result { - Ok(match outcome { - RemovalOutcome::Removed(_) => Step::Unlinked, - other => Step::Finished(other), - }) -} - -/// `result`, and if it is an error the object is put back first: a failure after the move leaves the -/// directory as it was found. -fn rolled_back( - quarantine: &Quarantine, - dir: &PinnedDir, - name: &OsStr, - result: Result<(), PinnedError>, -) -> Result<(), PinnedError> { - if result.is_err() { - quarantine.restore(&dir.handle, name); - } - result -} - -/// Settles the durability of the parent directory after the quarantine directory was dealt with. A sync -/// that fails is an error although the object is gone: a crash may undo the removal, and a rerun -/// converges. -fn settle( - dir: &PinnedDir, - step: Step, - observer: Observer<'_>, + _dir: &PinnedDir, + _quarantine: &OsStr, + _authenticate: impl FnOnce(&mut PinnedFile) -> Result, + _observer: Observer<'_>, ) -> Result { - match step { - Step::Finished(outcome) => Ok(outcome), - Step::Unlinked => { - observer(RemovalStage::BeforeSync)?; - Ok(RemovalOutcome::Removed(dir.sync()?)) - } - } + Err(PinnedError::Unsupported) } diff --git a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs index 9ddf54e70..9f70a0dc4 100644 --- a/crates/worldscript-secure-storage/src/pinned/windows/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/windows/removal.rs @@ -1,74 +1,28 @@ -//! 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`). +//! 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. use std::ffi::OsStr; -use std::fs::File; -use std::io; -use std::os::windows::io::AsRawHandle; -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}; +use crate::pinned::{Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome}; 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 { - 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) + Err(PinnedError::Unsupported) } pub(in crate::pinned) fn remove_dir( - dir: &PinnedDir, - name: &OsStr, - observer: Observer<'_>, + _dir: &PinnedDir, + _name: &OsStr, + _observer: Observer<'_>, ) -> Result { - 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) + Err(PinnedError::Unsupported) } -/// Windows creates no quarantine directory, so there is nothing to finish. pub(in crate::pinned) fn remove_leftover( _dir: &PinnedDir, _quarantine: &OsStr, @@ -77,39 +31,3 @@ 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 797f2ce8d..f83acd72a 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1697,95 +1697,42 @@ its result is recorded on the PR. removal primitives, and with them the claim that only the authenticated object is ever removed, are H1a-2. The sweep (H1b), the three producer-equivalence invariants (H1c) and stored-set reclamation (I1c) are not started. -### Bound removal through a pinned directory (Gate 4 slice H1a-2) - -Status words: `H1A_2_REMOVAL=IMPLEMENTED_TEST_SUPPORT_ONLY`, `H1_PROVED=NO` (H1b has not ported the sweep), -`R1_PRODUCTION_RECLAMATION=NOT_ADMITTED`, `I1C_PRODUCTION_GC=NOT_ADMITTED`, `GATE4_TERMINAL=NO`. The maintainer admitted this -slice on 2026-10-15 for the `test-support` feature only, without a production caller or a production deletion release. -With `test-support`, `PinnedDir` gains `remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` -(each with an observer form where it matters), `RemovalOutcome`, `RemovalStage`, `Observer` and `QUARANTINE_PREFIX`. -`PinnedDir::sync` (no deletion; `fsync` of the directory, not confirmed on Windows) is in every build. - -**The two designs this slice does not use.** (1) `fstatat` then `unlinkat(parent, name)` on Unix: POSIX has no -unlink-by-inode, so a swap of the last component between the two calls removes whatever has the name by then. -(2) `FILE_FLAG_DELETE_ON_CLOSE` on Windows: it is chosen when the handle is opened and cannot be taken back, so a -file opened that way before the authentication is deleted at close even if the authentication fails. - -**Unix: move aside, identify the open object, then unlink.** The authenticated file is held open with identity -`(dev, ino)`. A quarantine directory (`.h1q-<128 random bits>`, mode 0700, checked to be a directory of the effective -user without group or other access) is created in the pinned parent and held by descriptor, and -`renameat2(RENAME_NOREPLACE)` (macOS `renameatx_np(RENAME_EXCL)`) moves whatever the name is bound to at that instant -into it; nothing is ever overwritten. Only there is the object identified: it is looked at (a device node is not -opened), opened without following a link, and the identity of the open object is compared. If it is the authenticated -object it is unlinked from the quarantine directory, and the link count of the open object must have dropped by -one; otherwise it is moved back under its name (`Swapped { restored: true }`) or, if the name was taken meanwhile, -kept in the quarantine directory (`restored: false`), and in no case unlinked. A file system without -`RENAME_NOREPLACE` answers `Unsupported` before anything is touched (that answer is not exercised on the hosted -runners). A failure at any step after the move (an I/O error, a refused sync is separate) puts the object back first -(rollback). A directory is moved and removed the same way with `unlinkat(AT_REMOVEDIR)`; one that fills after the check -is moved back (`NotEmpty`). The parent is synced after the removal (`Removed(DirectoryDurability)`); a sync that fails is -an error although the object is gone, and a rerun converges (`NotFound`). - -**No exclusivity is assumed.** The mode and the random name keep out other users and accidental interference, not another -process of the same user, which can list the parent, find the directory and enter it. The design therefore holds when -the quarantine directory is manipulated at every point before the last identification (tests below), and its one -irreducible window is stated instead of hidden. *Residual:* no Unix primitive unlinks "this inode"; between the last -identification and the unlink, an actor that can write into the quarantine directory and wins that one-syscall race -can have its own regular file unlinked instead. The link count of the authenticated object then did not drop and the -removal reports `RemovalOutcome::Disturbed`; it cannot be undone. The actor gains nothing it could not do directly -(it can unlink its own file, and, being the same user, the live one). A directory in that window is never unlinked (the -unlink fails, the directory is put back). Hard links: removing one name leaves the other names (the link count is -exposed as `PinnedFile::link_count` for a caller that must refuse a linked file); the accounting tolerates a link made -in the window. - -**Leftovers and crashes.** A crash after the move, or an object that could not be put back, leaves a `.h1q-*` directory -holding the object as `x`. A sweep never removes it by itself. `remove_leftover_file(name, authenticate)` takes that -directory by its name (a name without the prefix is `InvalidName`), authenticates the file in it again and, only if -accepted, removes it like any other (an object that does not authenticate stays, an empty quarantine directory is -removed). Windows creates no quarantine directory (`Unsupported`). - -**Windows: authenticate on the handle, then a disposition on that handle.** The object is opened through the same -handle-relative lookup as 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 then `FileDispositionInfoEx` (delete, POSIX semantics) is set on that same handle. Failing -authentication, an error or dropping the handle deletes nothing; there is no fallback to the classic disposition (a Windows -or file system without POSIX delete semantics is `Unsupported`), and a read-only file is refused, not forced. The -disposition is the only new `unsafe` (`SetFileInformationByHandle` with one fixed structure, its layout pinned by a test; -no generic or safe wrapper over another class); the `process` feature of `rustix` (already locked) is added for -`geteuid`, and nothing else changes in `Cargo.lock`. - -**Proof (lib unit tests, so that the macOS and Windows evidence job runs the same cases).** Unix: the baseline runs the -naive check-then-`unlinkat` against the same swap and shows the live file destroyed, so the observer forces the race; a -swap of the last name, a directory, a symlink and a FIFO swapped in, a name taken again while the object is aside (kept -in the quarantine directory), a name taken after the authenticated file moved aside, a swap inside the quarantine -directory before the identification, renaming the root during the removal, a vanished name, a swapped or filled -directory; the last window is forced and `Disturbed` is the pinned answer, a directory in it is put back, a hard link -made in it leaves the object alive; the quarantine directory is mode 0700 and gone afterwards; an injected failure at -each stage after the move rolls back and leaves no quarantine directory, a failed sync is reported and a rerun converges; -a simulated crash (the observer panics) leaves the object in quarantine, a leftover removal refuses what does not -authenticate and finishes what does, and a foreign object left by a refused put-back is never removed by it. -Mutation-checked: skipping the identity comparison fails five of those cases, and skipping the link-count verification -fails the forced last window. Windows (hosted runner is the only oracle): the name of the authenticated file cannot be -renamed, deleted or replaced while the handle is held; 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; a read-only file is -refused. Everywhere: only the authenticated object goes; a refused, failing or wrong-identity authentication removes -nothing; links, directories and missing names are not removed as files; hard-linked names; an injected failure before -anything changed changes nothing. - -**Isolation.** `feature_isolation_removal_test` scans the sources: the modules that hold the primitives are declared -under the gate on every platform, no other file names them, and no other module named `removal` exists (with a scan that -proves it sees an ungated declaration and export). `feature_isolation_compile_test` proves it with the compiler: the -crate is built the way a dependent gets it (default features) and with `test-support`, and a small crate that calls a -removal method or imports `RemovalOutcome` fails with E0599 and E0432 against the first and compiles against the second, -while a control that names only `PinnedDir::open_root` compiles against both. The no-dependent and default-closure checks -of `feature_isolation_test` still decide who can request the feature. - -**Not claimed.** No caller exists and the sweep is not ported (H1b); the producer-equivalence invariants (H1c) and -stored-set reclamation (I1c) are not started; a device node swapped in is refused before it is opened, and cannot be -created by an unprivileged actor, but that is argued, not tested (it needs privilege); the `Unsupported` answers of -file systems without `RENAME_NOREPLACE` or POSIX delete are not exercised on the hosted runners; the Disturbed window is -bounded and detected, not closed. +### Bound removal through a pinned directory (Gate 4 slice H1a-2), part 1: the API and its isolation + +Status words: `H1A_2_REMOVAL=API_AND_ISOLATION_ONLY`, `H1_PROVED=NO`, `R1_PRODUCTION_RECLAMATION=NOT_ADMITTED`, +`I1C_PRODUCTION_GC=NOT_ADMITTED`, `GATE4_TERMINAL=NO`. The maintainer admitted H1a-2 on 2026-10-15 for the +`test-support` feature only, without a production caller or a production deletion release. A first draft of the whole slice +was 2,172 meaningful lines in one pull request, over the 1,200-line hard-advisory tier; it is cut along proof boundaries into +four stacked pull requests, each with the tests of its own behaviour: + +| Part | Content | Prerequisite | +|---|---|---| +| 1 (this) | `PinnedDir::{remove_authenticated_file, remove_empty_dir, remove_leftover_file}`, `RemovalOutcome`, `RemovalStage`, `Observer`, `QUARANTINE_PREFIX` behind `test-support`; `PinnedDir::sync` (no deletion) in every build; the isolation proofs; every call answers `Unsupported` | main | +| 2 | Unix removal of a file: quarantine directory, identification of the open object, unlink, rollback, `Stranded`, `Disturbed`, forced-swap tests | 1 | +| 3 | Unix removal of a directory, the leftover recovery and the crash cases | 2 | +| 4 | Windows: the delete handle through the relative lookup, read-only sharing, the POSIX disposition on that handle | 3 | + +**Isolation.** `feature_isolation_removal_test` scans the sources: the modules that hold the primitives are declared under +the gate on every platform, no other file names them, and no other module named `removal` exists (with a scan that proves it +sees an ungated declaration and export). `feature_isolation_compile_test` proves it with the compiler: the crate is built the +way a dependent gets it (default features) and with `test-support`, and a small crate that calls a removal method or imports +`RemovalOutcome` fails with E0599 and E0432 against the first and compiles against the second, while a control that names only +`PinnedDir::open_root` compiles against both; the nested builds use a target directory of their own (about 100 s). The +no-dependent and default-closure checks of `feature_isolation_test` still decide who can request the feature. A lib test +shows that in this part every removal answers `Unsupported` and removes nothing; `PinnedDir::sync` is `Confirmed` on Unix and +`NotConfirmed` on Windows. + +**The contract the later parts implement, and its limit.** Unix moves the entry into a private quarantine directory with +`RENAME_NOREPLACE`, identifies the open object there and only then unlinks it; anything else is put back, and a failure after +the move rolls back or is reported as `Stranded` (where the entry is). Windows opens the object through the handle-relative +lookup with delete access and read-only sharing, authenticates on that handle and sets the POSIX delete disposition on it. +**No Unix primitive unlinks "this inode"**, and the quarantine directory is not exclusive (mode 0700 and a random name keep out +other users and accidents, not another process of the same user): between the last identification and the unlink an actor that +can write into it and wins that one race can have its own object removed instead. That is detected after the fact, not +prevented (`RemovalOutcome::Disturbed`, by the link count of the authenticated object, a heuristic that an actor which also +changes that link count is not caught by), and it is not counted as a success. That the same user could delete the object +directly is a statement about a threat model, not a guarantee of identity: **whether this residual is acceptable for a +production sweep is a decision of the maintainer, to be taken before H1b**. + +**Not claimed.** No removal is implemented in this part; no caller exists and the sweep is not ported (H1b); H1c and I1c are not +started. From be6136b610d87ce42ffcbd1d43bcdc05e8ce318c Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:53:07 +0200 Subject: [PATCH 03/13] feat(core): remove a file on Unix by moving it aside and identifying the open object (#445) The authenticated file is moved into a quarantine directory with RENAME_NOREPLACE, the open object is identified there and only then unlinked. Anything else is put back, never unlinked. Every failure after the move is handled at one point: the entry goes back, or the outcome is Stranded and says where it is. The parent is synced whenever it changed. The quarantine directory is not assumed to be exclusive. The one window no Unix primitive closes is forced in a test and reported as Disturbed: detected after the fact by the link count of the authenticated object, not prevented. --- CHANGELOG.md | 2 + crates/worldscript-secure-storage/Cargo.toml | 5 +- .../src/pinned/tests/mod.rs | 2 + .../src/pinned/tests/remove.rs | 148 ++++- .../src/pinned/tests/remove_unix.rs | 557 ++++++++++++++++++ .../src/pinned/unix/removal.rs | 346 ++++++++++- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 44 ++ 7 files changed, 1089 insertions(+), 15 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 22707cfaf..4a72b160b 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 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. + - **R-15 Gate 4:** the API and the isolation of the bound removal, `test-support` only (slice H1a-2, part 1 of 4). `PinnedDir::remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` with `RemovalOutcome`, `RemovalStage` and `Observer` exist only with the test-only feature, have no caller and, in this part, answer `Unsupported` and remove nothing; the platform implementations follow in three stacked parts. A scan of the sources and a compile of a dependent with and without the feature keep the primitives out of every other build. `PinnedDir::sync` (no deletion) is added. The contract states what no Unix primitive can do (unlink "this inode": a same-user actor in the quarantine directory can win one race, which is detected after the fact as `Disturbed`, not prevented) and leaves the decision about that residual to the maintainer before any sweep uses it. No new `unsafe`, no new dependency feature, no new package. Issue #359. PR #1068. - **R-15 Gate 4:** handle-relative pinned I/O (slice H1a-1). A new parallel module `pinned` opens one directory with no-follow semantics, resolves it once to a link-free path, and reaches everything below it through a directory that is held open (`PinnedDir`, `PinnedFile`): bounded listing, bounded reading from the start of the file, identity and hard-link count; a link, junction or reparse point is refused at every component below the root, and a name must be exactly one ordinary component (on Windows also no stream, wildcard or device name). Unix uses `*at` calls on directory descriptors and checks the root's path without following a link; Windows pins a directory with a handle that does not share delete and opens children (`NtCreateFile` relative to the handle), listings (the handle's directory information) and entry kinds relative to it, so a directory converted into a reparse point in place leads nowhere. It changes nothing in the file system, has no caller and does not change `DurableFs`; the removal primitives follow in H1a-2 behind `test-support`. Local `unsafe` for the relative open, the listing and the final path, and one typed handle-query helper in `root_lock` (an `unsafe trait` with two implementations); the `Wdk_*` features of the `windows-sys` already locked, no new package. Issue #359. PR #1067. diff --git a/crates/worldscript-secure-storage/Cargo.toml b/crates/worldscript-secure-storage/Cargo.toml index aeed20b6a..a8cff79d3 100644 --- a/crates/worldscript-secure-storage/Cargo.toml +++ b/crates/worldscript-secure-storage/Cargo.toml @@ -35,10 +35,11 @@ zeroize_derive = "~1.4" # Gate 4 slice 4A (ยง11.1): the cross-process `root_commit_mutex` is an operating-system advisory # lock. rustix's safe `flock` on Unix (1.x, MSRV 1.63, already in the lockfile); on Windows one # `LockFileEx` call through windows-sys 0.52 (MSRV 1.56, already in the lockfile). -# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls; on Windows the relative `NtCreateFile` open +# Gate 4 slice H1a (`pinned`): the same rustix for `*at` calls (and, with `process`, `geteuid` for the +# quarantine directory of the test-only removal); on Windows the relative `NtCreateFile` open # (the `Wdk_*` features of windows-sys, no new package) and a few more calls. [target.'cfg(unix)'.dependencies] -rustix = { version = "1.1", default-features = false, features = ["fs", "std"] } +rustix = { version = "1.1", default-features = false, features = ["fs", "process", "std"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Wdk_Foundation", "Wdk_Storage_FileSystem"] } diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index b44352bcf..950f4ed6c 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -8,6 +8,8 @@ use std::sync::atomic::{AtomicU32, Ordering}; mod read; #[cfg(feature = "test-support")] mod remove; +#[cfg(all(feature = "test-support", unix))] +mod remove_unix; #[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 1094c9c69..03bfa29e7 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove.rs @@ -1,13 +1,155 @@ -//! Removal, in the slice that only brings the API and the isolation: every call refuses, so nothing is -//! removed and nothing claims a safety it does not have yet. +//! 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. 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 { + move |file| Ok(file.read_at_most(64)? == expected) +} + +/// What a completed removal reports on this platform. +#[cfg(unix)] +pub(super) fn removed() -> Result { + let durability = if cfg!(unix) { + DirectoryDurability::Confirmed + } else { + DirectoryDurability::NotConfirmed + }; + Ok(RemovalOutcome::Removed(durability)) +} + +#[cfg(unix)] +pub(super) fn names(pinned: &PinnedDir) -> Vec { + let mut listed: Vec = pinned + .entries(32) + .unwrap() + .into_iter() + .map(|name| name.to_string_lossy().into_owned()) + .collect(); + listed.sort(); + listed +} + +#[cfg(unix)] +#[test] +fn an_authenticated_file_is_removed_and_nothing_else_is() { + let scratch = Scratch::new("remove"); + 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 = pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + (outcome, names(&pinned), fs::read(root.join("b")).unwrap()), + (removed(), vec!["b".to_string()], b"beta".to_vec()) + ); +} + +#[cfg(unix)] +#[test] +fn a_refused_authentication_a_failing_one_and_a_wrong_identity_remove_nothing() { + let scratch = Scratch::new("refuse"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::write(root.join("other"), b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let expected = pinned.open_file("other").unwrap().identity(); + let refused = pinned.remove_authenticated_file("a", holds(b"something else")); + let failed = pinned.remove_authenticated_file("a", |_| Err(PinnedError::Busy)); + // The bytes match, but this is not the object the caller meant. + let wrong = pinned.remove_authenticated_file("a", move |file| Ok(file.identity() == expected)); + assert_eq!( + ( + refused, + failed, + wrong, + fs::read(root.join("a")).unwrap(), + names(&pinned) + ), + ( + Ok(RemovalOutcome::Refused), + Err(PinnedError::Busy), + Ok(RemovalOutcome::Refused), + b"alpha".to_vec(), + vec!["a".to_string(), "other".to_string()] + ) + ); +} + +#[cfg(unix)] +#[test] +fn a_link_a_directory_and_a_missing_name_are_not_removed_as_files() { + let scratch = Scratch::new("not-files"); + let root = scratch.root(); + let aside = scratch.aside(); + fs::create_dir(aside.join("target")).unwrap(); + fs::write(aside.join("target").join("kept"), b"kept").unwrap(); + link_to_dir(&aside.join("target"), &root.join("link")); + fs::create_dir(root.join("dir")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let remove = |name: &str| pinned.remove_authenticated_file(name, |_| Ok(true)); + assert_eq!( + ( + remove("link"), + remove("dir"), + remove("missing"), + fs::read(aside.join("target").join("kept")).unwrap(), + names(&pinned) + ), + ( + Err(PinnedError::Redirected), + Err(PinnedError::NotRegularFile), + Err(PinnedError::NotFound), + b"kept".to_vec(), + vec!["dir".to_string(), "link".to_string()] + ) + ); +} + +#[cfg(unix)] +#[test] +fn removing_a_hard_linked_name_leaves_the_other_names() { + let scratch = Scratch::new("hard-link"); + let root = scratch.root(); + fs::write(root.join("a"), b"alpha").unwrap(); + fs::hard_link(root.join("a"), root.join("a-too")).unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + let links = pinned.open_file("a").unwrap().link_count(); + let outcome = pinned.remove_authenticated_file("a", holds(b"alpha")); + assert_eq!( + ( + links, + outcome, + names(&pinned), + fs::read(root.join("a-too")).unwrap(), + pinned.open_file("a-too").unwrap().link_count() + ), + ( + Ok(2), + removed(), + vec!["a-too".to_string()], + b"alpha".to_vec(), + Ok(1) + ) + ); +} + +#[cfg(windows)] #[test] -fn every_removal_refuses_until_a_platform_implements_it_and_removes_nothing() { +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(); diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs new file mode 100644 index 000000000..02a315151 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -0,0 +1,557 @@ +//! Unix removal of a file under the swap the design exists for: another actor replaces the last +//! component of the name, or the entry inside the quarantine directory, between the steps of the +//! removal. Each case forces the swap through the observer at a chosen stage and pins what happens to +//! every object (by identity where the content could be mistaken). The counter-proofs show that the swap +//! really takes place: the baseline, and the asserted state of the displaced objects. + +use std::fs; +use std::os::fd::AsFd; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use rustix::fs::{unlinkat, AtFlags}; + +use super::remove::{holds, names, removed}; +use super::Scratch; +use crate::durable::DirectoryDurability; +use crate::pinned::unix::removal::{make_usable, INSIDE}; +use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; + +/// A pinned directory holding `a` = "alpha", with the paths a swap needs. +struct Fixture { + /// Keeps the directories until the test ends. + _scratch: Scratch, + root: PathBuf, + aside: PathBuf, + pinned: PinnedDir, +} + +fn fixture(label: &str) -> Fixture { + let scratch = Scratch::new(label); + let root = scratch.root(); + let aside = scratch.aside(); + fs::write(root.join("a"), b"alpha").unwrap(); + let pinned = PinnedDir::open_root(&root).unwrap(); + Fixture { + _scratch: scratch, + root, + aside, + pinned, + } +} + +/// The stages of a removal, without their payload. +#[derive(Clone, Copy, PartialEq)] +enum At { + Checked, + MovedAside, + Identified, + BeforeSync, +} + +/// The stage, and the path inside the quarantine directory once there is one. +fn at(stage: RemovalStage<'_>) -> (At, Option<&Path>) { + match stage { + RemovalStage::Checked => (At::Checked, None), + RemovalStage::MovedAside { quarantined } => (At::MovedAside, Some(quarantined)), + RemovalStage::Identified { quarantined } => (At::Identified, Some(quarantined)), + RemovalStage::BeforeSync => (At::BeforeSync, None), + } +} + +/// An observer that runs `action` at the stage `here` and otherwise does nothing. +fn on( + here: At, + mut action: impl FnMut(Option<&Path>), +) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { + move |stage| { + let (now, path) = at(stage); + if now == here { + action(path); + } + Ok(()) + } +} + +/// An observer that fails at the stage `here`. +fn failing_at(here: At) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedError> { + move |stage| { + if at(stage).0 == here { + return Err(denied()); + } + Ok(()) + } +} + +fn denied() -> PinnedError { + PinnedError::Io(std::io::ErrorKind::PermissionDenied) +} + +/// The last-component swap: the original is renamed out of the way and a live file takes its name. +fn swap_in_a_live_file(root: &Path, aside: &Path) { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::write(root.join("a"), b"live").unwrap(); +} + +/// The inode of the object at `path` (not following a link): who is who, whatever the content says. +fn ino(path: &Path) -> u64 { + fs::symlink_metadata(path).unwrap().ino() +} + +/// The quarantine directories a removal left in `root`. +fn quarantines(root: &Path) -> Vec { + let mut found: Vec = fs::read_dir(root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with(QUARANTINE_PREFIX) + }) + .collect(); + found.sort(); + found +} + +/// The content of the entry left in the only quarantine directory of `root`. +fn held_in_quarantine(root: &Path) -> Option> { + let all = quarantines(root); + assert!(all.len() <= 1, "{all:?}"); + fs::read(all.first()?.join(INSIDE)).ok() +} + +/// A name-based removal: identify the entry, let `swap` run, then unlink the name. +fn naive_removal(pinned: &PinnedDir, name: &str, swap: impl FnOnce()) -> rustix::io::Result<()> { + let authenticated = pinned.open_file(name).unwrap(); + assert!(pinned.entry_kind(name).is_ok()); + swap(); + drop(authenticated); + unlinkat(pinned.handle.as_fd(), name, AtFlags::empty()) +} + +#[test] +fn the_swap_destroys_the_live_file_of_a_name_based_removal_so_the_hook_forces_the_race() { + let f = fixture("baseline"); + let result = naive_removal(&f.pinned, "a", || swap_in_a_live_file(&f.root, &f.aside)); + assert_eq!( + ( + result, + f.root.join("a").exists(), + fs::read(f.aside.join("original")).unwrap() + ), + (Ok(()), false, b"alpha".to_vec()) + ); +} + +#[test] +fn a_swap_of_the_last_name_after_the_check_destroys_nothing() { + let f = fixture("swap"); + let original = ino(&f.root.join("a")); + let mut live = 0; + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| { + swap_in_a_live_file(&f.root, &f.aside); + live = ino(&f.root.join("a")); + }), + ); + assert_eq!( + ( + outcome, + ino(&f.root.join("a")) == live, + ino(&f.aside.join("original")) == original, + names(&f.pinned) + ), + ( + Ok(RemovalOutcome::Swapped { restored: true }), + true, + true, + vec!["a".to_string()] + ) + ); +} + +/// What a swap puts at the name, and whether it is still there afterwards. +type Swap = (&'static str, fn(&Path, &Path), fn(&Path, &Path) -> bool); + +#[test] +fn whatever_is_swapped_in_is_put_back_untouched() { + let swaps: [Swap; 4] = [ + ( + "directory", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::create_dir(root.join("a")).unwrap(); + fs::write(root.join("a").join("inside"), b"inside").unwrap(); + }, + |root, _| fs::read(root.join("a").join("inside")).ok() == Some(b"inside".to_vec()), + ), + ( + "symlink", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + fs::write(aside.join("target"), b"target").unwrap(); + std::os::unix::fs::symlink(aside.join("target"), root.join("a")).unwrap(); + }, + |root, aside| fs::read_link(root.join("a")).ok() == Some(aside.join("target")), + ), + ( + "fifo", + |root, aside| { + fs::rename(root.join("a"), aside.join("original")).unwrap(); + let made = std::process::Command::new("mkfifo") + .arg(root.join("a")) + .status() + .unwrap(); + assert!(made.success()); + }, + |root, _| fs::symlink_metadata(root.join("a")).is_ok_and(|m| m.file_type().is_fifo()), + ), + ( + // A file this process cannot open although it could open the authenticated one. + "unopenable", + |root, aside| { + swap_in_a_live_file(root, aside); + fs::set_permissions(root.join("a"), fs::Permissions::from_mode(0o000)).unwrap(); + }, + |root, _| fs::metadata(root.join("a")).unwrap().permissions().mode() & 0o777 == 0, + ), + ]; + let outcomes: Vec<_> = swaps + .into_iter() + .map(|(label, swap, intact)| { + let f = fixture(label); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| swap(&f.root, &f.aside)), + ); + ( + label, + outcome, + intact(&f.root, &f.aside), + fs::read(f.aside.join("original")).unwrap(), + quarantines(&f.root).len(), + ) + }) + .collect(); + let put_back = |label| { + let swapped = Ok(RemovalOutcome::Swapped { restored: true }); + (label, swapped, true, b"alpha".to_vec(), 0) + }; + assert_eq!( + outcomes, + [ + put_back("directory"), + put_back("symlink"), + put_back("fifo"), + put_back("unopenable") + ] + ); +} + +#[test] +fn a_name_taken_again_while_the_swapped_file_is_aside_leaves_every_object_intact() { + let f = fixture("retaken"); + let mut at_checked = on(At::Checked, |_| swap_in_a_live_file(&f.root, &f.aside)); + let mut at_moved = on(At::MovedAside, |_| { + fs::write(f.root.join("a"), b"retaken").unwrap() + }); + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + at_checked(stage)?; + at_moved(stage) + }); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + fs::read(f.aside.join("original")).unwrap(), + held_in_quarantine(&f.root) + ), + ( + Ok(RemovalOutcome::Swapped { restored: false }), + b"retaken".to_vec(), + b"alpha".to_vec(), + Some(b"live".to_vec()) + ) + ); +} + +#[test] +fn a_swap_inside_the_quarantine_directory_before_the_identification_is_never_unlinked() { + let f = fixture("inside"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let quarantined = quarantined.unwrap(); + fs::rename(quarantined, f.aside.join("moved-original")).unwrap(); + fs::write(quarantined, b"foreign").unwrap(); + }), + ); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + fs::read(f.aside.join("moved-original")).unwrap(), + names(&f.pinned) + ), + ( + Ok(RemovalOutcome::Swapped { restored: true }), + b"foreign".to_vec(), + b"alpha".to_vec(), + vec!["a".to_string()] + ) + ); +} + +/// What the actor does to the quarantine name in the last window, and what must hold afterwards. +type Window = (&'static str, fn(&Path, &Path), fn(&Fixture) -> bool); + +#[test] +fn the_last_window_is_forced_and_what_it_does_is_pinned() { + // No Unix primitive unlinks "this inode": after the last identification the unlink removes whatever + // the name is bound to. A foreign file that wins the race is lost and reported as `Disturbed`; a + // directory is never unlinked and goes back; a hard link made in the window leaves the object alive. + let windows: [Window; 3] = [ + ( + "foreign-file", + |quarantined, aside| { + fs::rename(quarantined, aside.join("original")).unwrap(); + fs::write(quarantined, b"foreign").unwrap(); + }, + |f| !f.root.join("a").exists(), + ), + ( + "foreign-directory", + |quarantined, aside| { + fs::rename(quarantined, aside.join("original")).unwrap(); + fs::create_dir(quarantined).unwrap(); + fs::write(quarantined.join("inside"), b"inside").unwrap(); + }, + |f| fs::read(f.root.join("a").join("inside")).ok() == Some(b"inside".to_vec()), + ), + ( + "hard-link", + |quarantined, aside| fs::hard_link(quarantined, aside.join("original")).unwrap(), + |f| !f.root.join("a").exists(), + ), + ]; + let outcomes: Vec<_> = windows + .into_iter() + .map(|(label, action, expected)| { + let f = fixture(label); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Identified, |quarantined| { + action(quarantined.unwrap(), &f.aside) + }), + ); + ( + label, + outcome.is_ok(), + matches!(outcome, Ok(RemovalOutcome::Disturbed)), + expected(&f), + fs::read(f.aside.join("original")).unwrap(), + quarantines(&f.root).len(), + ) + }) + .collect(); + let alpha = || b"alpha".to_vec(); + assert_eq!( + outcomes, + [ + ("foreign-file", true, true, true, alpha(), 0), + ("foreign-directory", false, false, true, alpha(), 0), + ("hard-link", true, false, true, alpha(), 0) + ] + ); +} + +#[test] +fn a_name_taken_after_the_move_keeps_the_new_file_and_a_vanished_name_is_reported() { + let taken = fixture("taken-after"); + let after = taken.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |_| { + fs::write(taken.root.join("a"), b"new").unwrap() + }), + ); + let gone = fixture("vanish"); + let vanished = gone.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| { + fs::remove_file(gone.root.join("a")).unwrap() + }), + ); + assert_eq!( + ( + after, + fs::read(taken.root.join("a")).unwrap(), + vanished, + names(&gone.pinned) + ), + ( + removed(), + b"new".to_vec(), + Ok(RemovalOutcome::Vanished), + Vec::::new() + ) + ); +} + +#[test] +fn renaming_the_root_during_the_removal_does_not_redirect_it() { + let f = fixture("root-renamed"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| { + fs::rename(&f.root, f.aside.join("moved-root")).unwrap(); + fs::create_dir(&f.root).unwrap(); + fs::write(f.root.join("a"), b"decoy").unwrap(); + }), + ); + let moved = fs::read_dir(f.aside.join("moved-root")).unwrap().count(); + assert_eq!( + (outcome, fs::read(f.root.join("a")).unwrap(), moved), + (removed(), b"decoy".to_vec(), 0) + ); +} + +#[test] +fn the_quarantine_directory_is_private_usable_under_any_umask_and_gone_afterwards() { + let f = fixture("private"); + let mut seen = None; + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let directory = quarantined.unwrap().parent().unwrap(); + seen = Some(fs::metadata(directory).unwrap().permissions().mode() & 0o777); + }), + ); + // A restrictive umask leaves a directory that nothing can be moved into; it is made usable. + let under_umask = format!("{QUARANTINE_PREFIX}{}", "a".repeat(32)); + fs::create_dir(f.root.join(&under_umask)).unwrap(); + fs::set_permissions(f.root.join(&under_umask), fs::Permissions::from_mode(0o500)).unwrap(); + let made = make_usable(&f.pinned.handle, under_umask.as_ref()); + let mode = fs::metadata(f.root.join(&under_umask)) + .unwrap() + .permissions() + .mode(); + assert_eq!( + ( + outcome, + seen, + made, + mode & 0o777, + quarantines(&f.root).len() + ), + (removed(), Some(0o700), Ok(()), 0o700, 1) + ); +} + +#[test] +fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found() { + let outcomes: Vec<_> = [At::Checked, At::MovedAside, At::Identified] + .into_iter() + .map(|here| { + let f = fixture("rollback"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut failing_at(here), + ); + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + quarantines(&f.root).len(), + ) + }) + .collect(); + assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 3]); +} + +#[test] +fn a_failed_sync_is_reported_for_a_removal_and_for_a_put_back_and_is_repeated_by_itself() { + let removal = fixture("sync-removal"); + let first = removal.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut failing_at(At::BeforeSync), + ); + // A rerun finds nothing to do and does not sync again; the directory sync is repeated by itself. + let rerun = removal + .pinned + .remove_authenticated_file("a", holds(b"alpha")); + let put_back = fixture("sync-put-back"); + let mut at_checked = on(At::Checked, |_| { + swap_in_a_live_file(&put_back.root, &put_back.aside) + }); + let swapped = + put_back + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + at_checked(stage)?; + failing_at(At::BeforeSync)(stage) + }); + assert_eq!( + ( + first, + rerun, + removal.pinned.sync(), + swapped, + fs::read(put_back.root.join("a")).unwrap() + ), + ( + Err(denied()), + Err(PinnedError::NotFound), + Ok(DirectoryDurability::Confirmed), + Err(denied()), + b"live".to_vec() + ) + ); +} + +/// The quarantine directory loses its search permission once the entry is inside: the inspection fails +/// and the entry cannot be put back either. +fn lock_the_quarantine(quarantined: Option<&Path>) { + let directory = quarantined.unwrap().parent().unwrap(); + fs::set_permissions(directory, fs::Permissions::from_mode(0o000)).unwrap(); +} + +#[test] +fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() { + assert!( + !rustix::process::geteuid().is_root(), + "the permission checks of this case do not apply to root; run the tests as a normal user" + ); + let f = fixture("stranded"); + let original = ino(&f.root.join("a")); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, lock_the_quarantine), + ); + let left = quarantines(&f.root); + // Opened up again, the entry is where the outcome said it is, and is the authenticated object. + fs::set_permissions(&left[0], fs::Permissions::from_mode(0o700)).unwrap(); + let same_object = ino(&left[0].join(INSIDE)) == original; + assert_eq!( + (outcome, f.root.join("a").exists(), left.len(), same_object), + ( + Ok(RemovalOutcome::Stranded { cause: denied() }), + false, + 1, + true + ) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 67be724e9..88333ca43 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -1,20 +1,345 @@ -//! Unix removal, test-support only: not implemented in this slice. Every call refuses with -//! `Unsupported`; the successor slices bring the quarantine-based removal of files, then of directories -//! and leftovers. See the parent `removal` module for the contract. +//! Unix removal, test-support only: move the entry into a quarantine directory, identify the open +//! object there, and only then unlink it. See the parent `removal` module for the reasoning and for the +//! one window that no Unix primitive closes. This part removes files; directories and the leftover +//! recovery follow and refuse with `Unsupported` until then. -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; -use crate::pinned::{Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome}; +use rustix::fs::{ + fchmod, fstat, mkdirat, openat, renameat_with, unlinkat, AtFlags, Mode, RenameFlags, +}; +use rustix::io::Errno; +use rustix::process::geteuid; + +use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; +use crate::durable::{DirectoryDurability, EntryKind}; +use crate::pinned::{ + ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, + QUARANTINE_PREFIX, +}; +use crate::random::{OsRandom, RandomSource}; + +/// The name an entry has inside the quarantine directory. +pub(in crate::pinned) const INSIDE: &str = "x"; + +/// A quarantine directory inside the pinned parent, held by descriptor. Whatever it holds is outside +/// every name grammar of the store and is never removed by a sweep. +struct Quarantine { + fd: File, + name: OsString, +} + +/// What is bound to the quarantine name right now. +enum Inside { + Absent, + /// Not the kind that was asked for (a link, a directory instead of a file, a FIFO), or not openable + /// by this process although the authenticated object was. + Foreign, + /// Opened, with the identity of the open object. + Object(File, ObjectIdentity), +} + +/// How a removal ended before the parent directory was synced. +enum Step { + /// Nothing in the directory changed. + Unchanged(RemovalOutcome), + /// The directory changed (an entry was moved and put back, or a quarantine directory came and + /// went); the outcome is final. + Changed(RemovalOutcome), + /// The object lost its name. + Unlinked, +} + +impl Quarantine { + fn create(parent: &DirHandle) -> Result { + let mut bits = [0u8; 16]; + OsRandom + .fill(&mut bits) + .map_err(|_| PinnedError::Unsupported)?; + let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); + let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); + mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; + match Self::made_usable(parent, &name) { + Ok(fd) => Ok(Quarantine { fd, name }), + Err(error) => { + let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); + Err(error) + } + } + } + + /// The directory just created, with the owner's access set explicitly (a restrictive umask would + /// otherwise leave a directory that nothing can be moved into) and checked. + fn made_usable(parent: &DirHandle, name: &OsStr) -> Result { + let fd = Self::open_owned(parent, name)?; + fchmod(&fd, Mode::RWXU).map_err(io_error)?; + let mode = Self::mode_of(&fd)?; + if mode & 0o077 == 0 && mode & 0o700 == 0o700 { + Ok(fd) + } else { + Err(PinnedError::Unsupported) + } + } + + /// The directory `name`, if it is a directory of this process's user (one of another user that was + /// swapped in is refused; the same user is not kept out, which the removal does not rely on). + fn open_owned(parent: &DirHandle, name: &OsStr) -> Result { + let fd = File::from(openat(parent, name, dir_flags(), Mode::empty()).map_err(io_error)?); + let stat = fstat(&fd).map_err(io_error)?; + if kind_of(&stat) == EntryKind::Directory && stat.st_uid == geteuid().as_raw() { + Ok(fd) + } else { + Err(PinnedError::Unsupported) + } + } + + fn mode_of(fd: &File) -> Result { + widen(fstat(fd).map_err(io_error)?.st_mode) + } + + /// Moves what `name` of `parent` is bound to right now into this directory; `false` if the name + /// leads nowhere. + fn adopt(&self, parent: &DirHandle, name: &OsStr) -> Result { + match renameat_with(parent, name, &self.fd, INSIDE, RenameFlags::NOREPLACE) { + Ok(()) => Ok(true), + Err(Errno::NOENT) => Ok(false), + // No RENAME_NOREPLACE here: nothing was moved, so nothing can be identified safely. + Err(Errno::INVAL | Errno::NOSYS | Errno::NOTSUP) => Err(PinnedError::Unsupported), + Err(errno) => Err(io_error(errno)), + } + } + + /// Looks at the entry before it is opened (opening a device node runs its driver), then opens it + /// without following a link and reports the identity of the open object. + fn inspect(&self, kind: EntryKind) -> Result { + let inside = OsStr::new(INSIDE); + match stat_child(&self.fd, inside)? { + None => return Ok(Inside::Absent), + Some(stat) if kind_of(&stat) != kind => return Ok(Inside::Foreign), + Some(_) => {} + } + let flags = if kind == EntryKind::Directory { + dir_flags() + } else { + file_flags() + }; + let object = match openat(&self.fd, inside, flags, Mode::empty()) { + Ok(fd) => File::from(fd), + Err(Errno::NOENT) => return Ok(Inside::Absent), + // Swapped between the look and the open, or not openable: not the object that was opened + // before, which this process could open. + Err(Errno::LOOP | Errno::NOTDIR | Errno::ISDIR | Errno::NXIO | Errno::ACCESS) => { + return Ok(Inside::Foreign) + } + Err(errno) => return Err(io_error(errno)), + }; + let metadata = object.metadata().map_err(PinnedError::from)?; + let right_kind = if kind == EntryKind::Directory { + metadata.is_dir() + } else { + metadata.is_file() + }; + if !right_kind { + return Ok(Inside::Foreign); + } + let identity = ObjectIdentity::new(metadata.dev(), u128::from(metadata.ino())); + Ok(Inside::Object(object, identity)) + } + + /// Moves the entry back under `name`; an error if that was not possible (the name is taken, or + /// anything else prevents it), in which case the entry stays here. + fn restore(&self, parent: &DirHandle, name: &OsStr) -> Result<(), PinnedError> { + renameat_with(&self.fd, INSIDE, parent, name, RenameFlags::NOREPLACE).map_err(io_error) + } + + fn path_inside(&self, parent_path: &Path) -> PathBuf { + parent_path.join(&self.name).join(INSIDE) + } + + /// Removes this directory if it is empty. A directory that still holds an entry is left. + fn discard(&self, parent: &DirHandle) { + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + } +} + +/// Makes the quarantine directory `name` of `parent` usable the way [`Quarantine::create`] does: the +/// owner's access is set explicitly, whatever the umask did to the mode it was created with. +#[cfg(test)] +pub(in crate::pinned) fn make_usable(parent: &DirHandle, name: &OsStr) -> Result<(), PinnedError> { + Quarantine::made_usable(parent, name).map(|_| ()) +} + +/// Unlinks the entry that was identified in the quarantine directory and asks the open object whether +/// it was the one that lost a name: its link count must have dropped. That is a check after the fact +/// and a heuristic, not a proof of identity (see the residual in the parent module): an actor that also +/// changes the link count of the authenticated object in the same window is not noticed. +fn unlink_verified( + quarantine: &Quarantine, + object: &File, + kind: EntryKind, +) -> Result { + let before = object.metadata().map_err(PinnedError::from)?.nlink(); + let flags = if kind == EntryKind::Directory { + AtFlags::REMOVEDIR + } else { + AtFlags::empty() + }; + match unlinkat(&quarantine.fd, INSIDE, flags) { + Ok(()) => {} + Err(Errno::NOTEMPTY | Errno::EXIST) if kind == EntryKind::Directory => { + return Ok(RemovalOutcome::NotEmpty) + } + Err(Errno::NOENT) => return Ok(RemovalOutcome::Vanished), + Err(errno) => return Err(io_error(errno)), + } + // Nothing can be said about a removal that cannot be checked afterwards. + let Ok(after) = object.metadata().map(|metadata| metadata.nlink()) else { + return Ok(RemovalOutcome::Disturbed); + }; + if after < before { + Ok(RemovalOutcome::Removed(DirectoryDurability::Confirmed)) + } else { + Ok(RemovalOutcome::Disturbed) + } +} + +/// The entry of a removal: its name, the object it must turn out to be and the kind it must have. +struct Target<'a> { + name: &'a OsStr, + expected: ObjectIdentity, + kind: EntryKind, +} + +/// One removal in progress: the directory, the entry and the quarantine directory it moves through. +struct Removal<'a> { + dir: &'a PinnedDir, + target: Target<'a>, + quarantine: Quarantine, +} 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) + let mut file = dir.open_file(name)?; + if !authenticate(&mut file)? { + return Ok(RemovalOutcome::Refused); + } + observer(RemovalStage::Checked)?; + let target = Target { + name, + expected: file.identity(), + kind: EntryKind::File, + }; + remove_moved(dir, target, observer) +} + +/// Removes the entry of `target` from `dir` if, once moved aside, it is the object it must be. +fn remove_moved( + dir: &PinnedDir, + target: Target<'_>, + observer: Observer<'_>, +) -> Result { + let removal = Removal { + dir, + target, + quarantine: Quarantine::create(&dir.handle)?, + }; + let step = removal.detach(observer); + removal.quarantine.discard(&dir.handle); + settle(dir, step?, observer) +} + +impl Removal<'_> { + /// Moves the entry into the quarantine directory and judges what arrived there. Whatever fails after + /// the move puts the entry back first; if that is impossible the removal says so + /// ([`RemovalOutcome::Stranded`]) instead of reporting an error that hides where the entry is. + fn detach(&self, observer: Observer<'_>) -> Result { + if !self.quarantine.adopt(&self.dir.handle, self.target.name)? { + return Ok(Step::Unchanged(RemovalOutcome::Vanished)); + } + match self.judge(observer) { + Err(cause) => match self.restore() { + Ok(()) => Err(cause), + Err(_) => Ok(Step::Unchanged(RemovalOutcome::Stranded { cause })), + }, + step => step, + } + } + + fn judge(&self, observer: Observer<'_>) -> Result { + let inside = self.quarantine.path_inside(&self.dir.path); + observer(RemovalStage::MovedAside { + quarantined: &inside, + })?; + match self.quarantine.inspect(self.target.kind)? { + Inside::Absent => Ok(Step::Changed(RemovalOutcome::Vanished)), + Inside::Object(object, found) if found == self.target.expected => { + self.conclude(&object, observer) + } + Inside::Object(..) | Inside::Foreign => { + let restored = self.restore().is_ok(); + Ok(Step::Changed(RemovalOutcome::Swapped { restored })) + } + } + } + + /// The last window: the entry in the quarantine directory is the authenticated object. + fn conclude(&self, object: &File, observer: Observer<'_>) -> Result { + let inside = self.quarantine.path_inside(&self.dir.path); + observer(RemovalStage::Identified { + quarantined: &inside, + })?; + match unlink_verified(&self.quarantine, object, self.target.kind)? { + // A directory that filled after the check goes back where it was. + RemovalOutcome::NotEmpty => { + self.restore()?; + Ok(Step::Changed(RemovalOutcome::NotEmpty)) + } + outcome => Ok(unlinked(outcome)), + } + } + + fn restore(&self) -> Result<(), PinnedError> { + self.quarantine.restore(&self.dir.handle, self.target.name) + } +} + +fn unlinked(outcome: RemovalOutcome) -> Step { + match outcome { + RemovalOutcome::Removed(_) => Step::Unlinked, + other => Step::Changed(other), + } +} + +/// Syncs the parent directory if anything in it changed. A sync that fails is an error although the +/// entry is gone or back: a crash may undo that, and the caller repeats [`PinnedDir::sync`] (a rerun of +/// the removal finds nothing to do and would not sync). +fn settle( + dir: &PinnedDir, + step: Step, + observer: Observer<'_>, +) -> Result { + match step { + Step::Unchanged(outcome) => Ok(outcome), + Step::Changed(outcome) => { + observer(RemovalStage::BeforeSync)?; + dir.sync()?; + Ok(outcome) + } + Step::Unlinked => { + observer(RemovalStage::BeforeSync)?; + Ok(RemovalOutcome::Removed(dir.sync()?)) + } + } } +/// Removing a directory follows in the next part. pub(in crate::pinned) fn remove_dir( _dir: &PinnedDir, _name: &OsStr, @@ -23,6 +348,7 @@ pub(in crate::pinned) fn remove_dir( Err(PinnedError::Unsupported) } +/// Finishing a leftover quarantine directory follows in the next part. pub(in crate::pinned) fn remove_leftover( _dir: &PinnedDir, _quarantine: &OsStr, diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index f83acd72a..c20255dc8 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1736,3 +1736,47 @@ production sweep is a decision of the maintainer, to be taken before H1b**. **Not claimed.** No removal is implemented in this part; no caller exists and the sweep is not ported (H1b); H1c and I1c are not started. + +#### Part 2: removal of a file on Unix + +Status words: `H1A_2_UNIX_FILE_REMOVAL=IMPLEMENTED_TEST_SUPPORT_ONLY`, `H1_PROVED=NO`. The authenticated file is held open with +identity `(dev, ino)`. A quarantine directory (`.h1q-` and 32 hexadecimal digits from the operating system's random source, +a directory of the effective user, mode 0700 set explicitly with `fchmod` so that a restrictive umask cannot leave a +directory nothing can be moved into) is created in the pinned parent and held by descriptor, and +`renameat2(RENAME_NOREPLACE)` (macOS `renameatx_np(RENAME_EXCL)`) moves whatever the name is bound to at that instant into +it; nothing is overwritten. Only there is the entry identified: it is looked at (a device node is not opened), opened without +following a link, and the identity of the open object is compared. If it is the authenticated object it is unlinked from the +quarantine directory; anything else (a live file, a link, a directory, a file this process cannot open) is moved back +(`Swapped { restored: true }`) or, if the name was taken meanwhile, kept in the quarantine directory (`restored: false`), and +never unlinked. A file system without `RENAME_NOREPLACE` answers `Unsupported` before anything is touched (not exercised on +the hosted runners). **Every failure after the move is handled at one point:** the entry is put back and the error is +returned, or, if it cannot be put back, the outcome is `Stranded { cause }` (the entry is in the quarantine directory as `x`, +nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed; a +sync that fails is an error although the entry is gone or back, and the caller repeats `PinnedDir::sync` (a rerun of the +removal finds nothing to do and would not sync). + +**The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find +it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the +last identification no Unix primitive unlinks "this inode": the unlink removes the name's current binding, so an actor that +wins that one race can have its own file removed instead. This is **detected after the fact, not prevented**: the link count of +the open authenticated object must have dropped, otherwise the outcome is `Disturbed` (also when the count cannot be read +afterwards), which a caller never counts as a success. The check is a heuristic: an actor that also changes the link count of the +authenticated object in the same window is not noticed (Codex, PR #1068). A directory in that window is not unlinked by a file +removal (it goes back). Whether this is acceptable for a sweep is the maintainer's decision before H1b. + +**Proof (lib unit tests, so that the macOS evidence job runs them too).** The baseline runs the naive check-then-`unlinkat` +against the same swap and shows the live file destroyed, so the observer forces the race. A swap of the last name; a directory, +a symlink, a FIFO and a file this process cannot open swapped in (each put back, identified by inode, none left in quarantine); +a name taken again while the entry is aside (kept in quarantine, both live objects intact); a swap inside the quarantine +directory before the identification; the last window forced with a foreign file (`Disturbed`, the original intact), a +directory (goes back) and a hard link (object alive under the other name); a name taken after the move, a vanished name, +renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with +mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported +for a removal and for a put-back and repeated by `sync`; a quarantine directory that loses its search permission after the +move gives `Stranded` with the entry still the authenticated inode. Mutation-checked: skipping the identity comparison fails +five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a +failed inspection fails the `Stranded` case. + +**Not claimed.** Directories and the leftover recovery are part 3 (they answer `Unsupported` here); Windows is part 4; the +Disturbed window is bounded and detected, not closed; a device node swapped in is refused before it is opened and cannot be +created by an unprivileged actor, which is argued, not tested. From 1226db156d221ebe09030cefceb68d3a5cc2c902 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:18:39 +0200 Subject: [PATCH 04/13] test(core): keep the swap table of the put-back test in a function of its own (#445) CodeScene counted the table as part of a 73-line test; the table is data, the test is the loop over it. --- .../src/pinned/tests/remove_unix.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 02a315151..cf62aa1cf 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -176,9 +176,9 @@ fn a_swap_of_the_last_name_after_the_check_destroys_nothing() { /// What a swap puts at the name, and whether it is still there afterwards. type Swap = (&'static str, fn(&Path, &Path), fn(&Path, &Path) -> bool); -#[test] -fn whatever_is_swapped_in_is_put_back_untouched() { - let swaps: [Swap; 4] = [ +/// The swaps that the removal must put back untouched. +fn swaps() -> [Swap; 4] { + [ ( "directory", |root, aside| { @@ -218,8 +218,12 @@ fn whatever_is_swapped_in_is_put_back_untouched() { }, |root, _| fs::metadata(root.join("a")).unwrap().permissions().mode() & 0o777 == 0, ), - ]; - let outcomes: Vec<_> = swaps + ] +} + +#[test] +fn whatever_is_swapped_in_is_put_back_untouched() { + let outcomes: Vec<_> = swaps() .into_iter() .map(|(label, swap, intact)| { let f = fixture(label); From eb9eb870113d430d1dd28f1416d36f1e25a6b09b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:53:14 +0200 Subject: [PATCH 05/13] fix(core): sync after a rollback, keep the cleanup bound to the directory that was made (#445) Review findings on the Unix file removal: - A failure after the move that put the entry back, or left it in the quarantine directory, returned without syncing the parent. The sync is now attempted after both (best effort, so it does not replace the failure being reported) and the BeforeSync stage is reached, which a test observes. - The quarantine directory was removed by its name alone, so a directory that an actor put at that name after renaming the held one away could be removed. The cleanup now checks that the name still leads to the directory that was created; a test forces the swap. - The permission-based Stranded test failed for root. It now skips with a message for root, and the same logic is covered without permissions by a case that blocks the put-back by taking the name again. --- .../src/pinned/tests/remove_unix.rs | 95 ++++++++++++++++--- .../src/pinned/unix/removal.rs | 51 ++++++++-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 13 ++- 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index cf62aa1cf..7fb2689d0 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -464,24 +464,35 @@ fn the_quarantine_directory_is_private_usable_under_any_umask_and_gone_afterward } #[test] -fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found() { +fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found_and_syncs_it_after_a_move() { let outcomes: Vec<_> = [At::Checked, At::MovedAside, At::Identified] .into_iter() .map(|here| { let f = fixture("rollback"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut failing_at(here), - ); + let synced = std::cell::Cell::new(false); + let outcome = + f.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if at(stage).0 == At::BeforeSync { + synced.set(true); + return Ok(()); + } + failing_at(here)(stage) + }); ( outcome, fs::read(f.root.join("a")).unwrap(), quarantines(&f.root).len(), + synced.get(), ) }) .collect(); - assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 3]); + let rolled_back = |synced| (Err(denied()), b"alpha".to_vec(), 0, synced); + // Nothing moved before the check, so there is nothing to sync; after the move the directory changed. + assert_eq!( + outcomes, + vec![rolled_back(false), rolled_back(true), rolled_back(true)] + ); } #[test] @@ -533,11 +544,12 @@ fn lock_the_quarantine(quarantined: Option<&Path>) { } #[test] -fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() { - assert!( - !rustix::process::geteuid().is_root(), - "the permission checks of this case do not apply to root; run the tests as a normal user" - ); +fn a_search_permission_lost_after_the_move_is_reported_as_stranded() { + if rustix::process::geteuid().is_root() { + // Root is not kept out by permissions; the same logic is covered without them by the next test. + eprintln!("skipped: permissions do not apply to root"); + return; + } let f = fixture("stranded"); let original = ino(&f.root.join("a")); let outcome = f.pinned.remove_authenticated_file_observed( @@ -559,3 +571,62 @@ fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() ) ); } + +#[test] +fn an_error_after_the_move_whose_put_back_is_blocked_is_reported_as_stranded_whoever_runs_it() { + let f = fixture("stranded-injected"); + let original = ino(&f.root.join("a")); + let mut synced = false; + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + match at(stage) { + // The name is taken again and the observer fails: the entry cannot go back. + (At::MovedAside, _) => { + fs::write(f.root.join("a"), b"taken").unwrap(); + return Err(denied()); + } + (At::BeforeSync, _) => synced = true, + _ => {} + } + Ok(()) + }); + let left = quarantines(&f.root); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + left.len(), + ino(&left[0].join(INSIDE)) == original, + synced + ), + ( + Ok(RemovalOutcome::Stranded { cause: denied() }), + b"taken".to_vec(), + 1, + true, + true + ) + ); +} + +#[test] +fn a_quarantine_name_that_leads_somewhere_else_at_the_end_is_not_removed() { + // The held directory is renamed away and an empty directory of the actor takes its name: the + // removal goes on in the directory it holds and leaves the actor's directory alone. + let f = fixture("discard"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let directory = quarantined.unwrap().parent().unwrap(); + fs::rename(directory, f.aside.join("held")).unwrap(); + fs::create_dir(directory).unwrap(); + }), + ); + let held = fs::read_dir(f.aside.join("held")).unwrap().count(); + assert_eq!( + (outcome, held, quarantines(&f.root).len()), + (removed(), 0, 1) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 88333ca43..7a1208fc5 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -14,7 +14,9 @@ use rustix::fs::{ use rustix::io::Errno; use rustix::process::geteuid; -use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; +use super::{ + dir_flags, file_flags, identity_from, io_error, kind_of, stat_child, widen, DirHandle, +}; use crate::durable::{DirectoryDurability, EntryKind}; use crate::pinned::{ ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, @@ -30,6 +32,8 @@ pub(in crate::pinned) const INSIDE: &str = "x"; struct Quarantine { fd: File, name: OsString, + /// The directory that was created, to tell it from whatever the name leads to later. + identity: ObjectIdentity, } /// What is bound to the quarantine name right now. @@ -51,6 +55,10 @@ enum Step { Changed(RemovalOutcome), /// The object lost its name. Unlinked, + /// The entry was moved aside and put back; this is why the removal stopped. + RolledBack(PinnedError), + /// The entry could not be put back and stays in the quarantine directory. + Stranded(PinnedError), } impl Quarantine { @@ -62,8 +70,8 @@ impl Quarantine { let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; - match Self::made_usable(parent, &name) { - Ok(fd) => Ok(Quarantine { fd, name }), + match Self::made_usable(parent, &name).and_then(|fd| Self::held(fd, name.clone())) { + Ok(held) => Ok(held), Err(error) => { let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); Err(error) @@ -71,6 +79,11 @@ impl Quarantine { } } + fn held(fd: File, name: OsString) -> Result { + let identity = identity_from(&fstat(&fd).map_err(io_error)?)?; + Ok(Quarantine { fd, name, identity }) + } + /// The directory just created, with the owner's access set explicitly (a restrictive umask would /// otherwise leave a directory that nothing can be moved into) and checked. fn made_usable(parent: &DirHandle, name: &OsStr) -> Result { @@ -159,9 +172,18 @@ impl Quarantine { parent_path.join(&self.name).join(INSIDE) } - /// Removes this directory if it is empty. A directory that still holds an entry is left. + /// Removes this directory if it is empty. A directory that still holds an entry is left, and so is + /// whatever the name leads to if that is no longer the directory that was created (the name is all + /// there is to remove it by; what is removed is at most an empty directory). fn discard(&self, parent: &DirHandle) { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + let still_ours = stat_child(parent, &self.name) + .ok() + .flatten() + .and_then(|stat| identity_from(&stat).ok()) + .is_some_and(|found| found == self.identity); + if still_ours { + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + } } } @@ -265,8 +287,8 @@ impl Removal<'_> { } match self.judge(observer) { Err(cause) => match self.restore() { - Ok(()) => Err(cause), - Err(_) => Ok(Step::Unchanged(RemovalOutcome::Stranded { cause })), + Ok(()) => Ok(Step::RolledBack(cause)), + Err(_) => Ok(Step::Stranded(cause)), }, step => step, } @@ -326,6 +348,14 @@ fn settle( observer: Observer<'_>, ) -> Result { match step { + Step::RolledBack(cause) => { + sync_after_failure(dir, observer); + Err(cause) + } + Step::Stranded(cause) => { + sync_after_failure(dir, observer); + Ok(RemovalOutcome::Stranded { cause }) + } Step::Unchanged(outcome) => Ok(outcome), Step::Changed(outcome) => { observer(RemovalStage::BeforeSync)?; @@ -357,3 +387,10 @@ pub(in crate::pinned) fn remove_leftover( ) -> Result { Err(PinnedError::Unsupported) } + +/// After a failure that moved an entry aside and back (or left it aside) the directory has changed too: +/// the sync is attempted, and its own failure does not replace the failure that is being reported. +fn sync_after_failure(dir: &PinnedDir, observer: Observer<'_>) { + let _ = observer(RemovalStage::BeforeSync); + let _ = dir.sync(); +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index c20255dc8..2cb60e1eb 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1751,9 +1751,12 @@ quarantine directory; anything else (a live file, a link, a directory, a file th never unlinked. A file system without `RENAME_NOREPLACE` answers `Unsupported` before anything is touched (not exercised on the hosted runners). **Every failure after the move is handled at one point:** the entry is put back and the error is returned, or, if it cannot be put back, the outcome is `Stranded { cause }` (the entry is in the quarantine directory as `x`, -nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed; a -sync that fails is an error although the entry is gone or back, and the caller repeats `PinnedDir::sync` (a rerun of the -removal finds nothing to do and would not sync). +nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed: after +a removal and after a put-back as a step of the removal (a sync that fails is an error although the entry is gone or back, and the +caller repeats `PinnedDir::sync`; a rerun of the removal finds nothing to do and would not sync), and after a rolled-back or +stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). The +quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an +actor put at that name is left alone (what could be removed is at most an empty directory). **The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the @@ -1773,7 +1776,9 @@ directory (goes back) and a hard link (object alive under the other name); a nam renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported for a removal and for a put-back and repeated by `sync`; a quarantine directory that loses its search permission after the -move gives `Stranded` with the entry still the authenticated inode. Mutation-checked: skipping the identity comparison fails +move gives `Stranded` with the entry still the authenticated inode, and the same case without permissions (an observer that +fails while the name is taken again, so it runs the same for root); a quarantine directory renamed away and replaced by an empty +directory of the actor is not removed. Mutation-checked: skipping the identity comparison fails five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a failed inspection fails the `Stranded` case. From 54dcf086fd6eaa1a790a967c931135ecae4f3c4c Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:13:26 +0200 Subject: [PATCH 06/13] fix(core): sync what the removal changed in every outcome, state the creation race (#445) More review findings on the Unix file removal: - The path where the name vanished before the move created and removed a quarantine directory and reported no change; the parent is now synced. - An entry that stays in the quarantine directory (put-back blocked, or stranded) is retained with a best-effort sync of that directory as well as of the parent. - That the directory behind the quarantine name is the one mkdirat made cannot be proved, since mkdirat returns no descriptor. The documentation says so and why it does not widen the window the contract already states. --- .../src/pinned/tests/remove_unix.rs | 21 ++++++++++++------- .../src/pinned/unix/removal.rs | 21 ++++++++++++++----- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 5 ++++- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 7fb2689d0..9fb72ab85 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -388,24 +388,31 @@ fn a_name_taken_after_the_move_keeps_the_new_file_and_a_vanished_name_is_reporte }), ); let gone = fixture("vanish"); - let vanished = gone.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| { - fs::remove_file(gone.root.join("a")).unwrap() - }), - ); + let mut synced = false; + let vanished = + gone.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + match at(stage) { + (At::Checked, _) => fs::remove_file(gone.root.join("a")).unwrap(), + // The quarantine directory came and went, which changed the parent. + (At::BeforeSync, _) => synced = true, + _ => {} + } + Ok(()) + }); assert_eq!( ( after, fs::read(taken.root.join("a")).unwrap(), vanished, + synced, names(&gone.pinned) ), ( removed(), b"new".to_vec(), Ok(RemovalOutcome::Vanished), + true, Vec::::new() ) ); diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 7a1208fc5..19ad04d15 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -48,8 +48,6 @@ enum Inside { /// How a removal ended before the parent directory was synced. enum Step { - /// Nothing in the directory changed. - Unchanged(RemovalOutcome), /// The directory changed (an entry was moved and put back, or a quarantine directory came and /// went); the outcome is final. Changed(RemovalOutcome), @@ -283,12 +281,16 @@ impl Removal<'_> { /// ([`RemovalOutcome::Stranded`]) instead of reporting an error that hides where the entry is. fn detach(&self, observer: Observer<'_>) -> Result { if !self.quarantine.adopt(&self.dir.handle, self.target.name)? { - return Ok(Step::Unchanged(RemovalOutcome::Vanished)); + // The quarantine directory came and went: that changed the parent too. + return Ok(Step::Changed(RemovalOutcome::Vanished)); } match self.judge(observer) { Err(cause) => match self.restore() { Ok(()) => Ok(Step::RolledBack(cause)), - Err(_) => Ok(Step::Stranded(cause)), + Err(_) => { + self.keep_the_entry(); + Ok(Step::Stranded(cause)) + } }, step => step, } @@ -306,6 +308,9 @@ impl Removal<'_> { } Inside::Object(..) | Inside::Foreign => { let restored = self.restore().is_ok(); + if !restored { + self.keep_the_entry(); + } Ok(Step::Changed(RemovalOutcome::Swapped { restored })) } } @@ -330,6 +335,13 @@ impl Removal<'_> { fn restore(&self) -> Result<(), PinnedError> { self.quarantine.restore(&self.dir.handle, self.target.name) } + + /// The entry stays in the quarantine directory: that directory changed as well as the parent, so + /// it is synced too (best effort; a failure here does not replace the outcome that says where the + /// entry is). + fn keep_the_entry(&self) { + let _ = self.quarantine.fd.sync_all(); + } } fn unlinked(outcome: RemovalOutcome) -> Step { @@ -356,7 +368,6 @@ fn settle( sync_after_failure(dir, observer); Ok(RemovalOutcome::Stranded { cause }) } - Step::Unchanged(outcome) => Ok(outcome), Step::Changed(outcome) => { observer(RemovalStage::BeforeSync)?; dir.sync()?; diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 2cb60e1eb..837f41531 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1756,7 +1756,10 @@ a removal and after a put-back as a step of the removal (a sync that fails is an caller repeats `PinnedDir::sync`; a rerun of the removal finds nothing to do and would not sync), and after a rolled-back or stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). The quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an -actor put at that name is left alone (what could be removed is at most an empty directory). +actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no +descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an +actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine +directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent. **The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the From 60d6db2b22ee90c5bc7ca11e497401cd006966dc Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:27:52 +0200 Subject: [PATCH 07/13] fix(core): report a quarantine that was renamed away, sync a failed move, refuse an unopenable one (#445) Further review findings on the Unix file removal: - An entry that stays in the quarantine directory is only reported as being there if the recorded name still leads to that directory. If an actor renamed it away the outcome is Disturbed, which now also covers an entry that cannot be found by name. A test forces the rename. - A failure to move the entry at all (renameat2 unsupported or failing) created and removed a quarantine directory without syncing the parent; it now goes through the same best-effort sync as a rollback. - A umask that takes the owner's read access away leaves a quarantine directory that cannot be opened. The removal is refused (fail closed) and a test pins that, instead of the documentation promising more. --- .../src/pinned/removal.rs | 10 +++-- .../src/pinned/tests/remove_unix.rs | 44 +++++++++++++++++++ .../src/pinned/unix/removal.rs | 40 ++++++++++------- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 6 ++- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 14299abaf..0525cf7b2 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -63,10 +63,12 @@ pub enum RemovalOutcome { /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine /// directory (`.h1q-*`, never removed by a sweep). Swapped { restored: bool }, - /// Unix only: after the unlink the authenticated object had not lost a name, so the name was bound - /// to another object when it was unlinked (see the window in the module documentation). That - /// object is gone and nothing can be undone: the caller must treat the directory as tampered with, - /// and never count this as a success. + /// Unix only: the quarantine directory was tampered with. Either the authenticated object had not + /// lost a name after the unlink, so the name was bound to another object when it was unlinked (see + /// the window in the module documentation), and that object is gone; or the entry stayed in the + /// quarantine directory but an actor renamed that directory away, so the entry cannot be found by + /// the name that was recorded. Nothing can be undone: the caller must treat the directory as + /// tampered with, and never count this as a success. Disturbed, /// Unix only: an error happened after the entry was moved into the quarantine directory and it /// could not be put back (`cause` is the error that started it). The entry is in the quarantine diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 9fb72ab85..17bd660a9 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -637,3 +637,47 @@ fn a_quarantine_name_that_leads_somewhere_else_at_the_end_is_not_removed() { (removed(), 0, 1) ); } + +#[test] +fn a_quarantine_directory_the_owner_cannot_open_is_refused_and_a_renamed_one_is_reported() { + if rustix::process::geteuid().is_root() { + eprintln!("skipped: permissions do not apply to root"); + return; + } + // A umask that takes the owner's read access away leaves a directory that cannot be opened: the + // removal is refused (fail closed) and nothing of the store is touched. + let locked = fixture("owner-masked"); + let name = format!("{QUARANTINE_PREFIX}{}", "b".repeat(32)); + fs::create_dir(locked.root.join(&name)).unwrap(); + fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o000)).unwrap(); + let refused = make_usable(&locked.pinned.handle, name.as_ref()); + fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o700)).unwrap(); + // The held quarantine directory is renamed away and the name taken again while the observer fails: + // the entry cannot go back and cannot be found by the recorded name either. + let f = fixture("renamed-quarantine"); + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if let (At::MovedAside, Some(inside)) = at(stage) { + let directory = inside.parent().unwrap(); + fs::rename(directory, f.aside.join("held")).unwrap(); + fs::write(f.root.join("a"), b"taken").unwrap(); + return Err(denied()); + } + Ok(()) + }); + assert_eq!( + ( + refused, + outcome, + fs::read(f.aside.join("held").join(INSIDE)).unwrap(), + fs::read(f.root.join("a")).unwrap() + ), + ( + Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)), + Ok(RemovalOutcome::Disturbed), + b"alpha".to_vec(), + b"taken".to_vec() + ) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 19ad04d15..8840da6f7 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -174,14 +174,18 @@ impl Quarantine { /// whatever the name leads to if that is no longer the directory that was created (the name is all /// there is to remove it by; what is removed is at most an empty directory). fn discard(&self, parent: &DirHandle) { - let still_ours = stat_child(parent, &self.name) + if self.is_named(parent) { + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + } + } + + /// Whether the name still leads to the directory that was created. + fn is_named(&self, parent: &DirHandle) -> bool { + stat_child(parent, &self.name) .ok() .flatten() .and_then(|stat| identity_from(&stat).ok()) - .is_some_and(|found| found == self.identity); - if still_ours { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); - } + .is_some_and(|found| found == self.identity) } } @@ -280,17 +284,17 @@ impl Removal<'_> { /// the move puts the entry back first; if that is impossible the removal says so /// ([`RemovalOutcome::Stranded`]) instead of reporting an error that hides where the entry is. fn detach(&self, observer: Observer<'_>) -> Result { - if !self.quarantine.adopt(&self.dir.handle, self.target.name)? { - // The quarantine directory came and went: that changed the parent too. - return Ok(Step::Changed(RemovalOutcome::Vanished)); + match self.quarantine.adopt(&self.dir.handle, self.target.name) { + // Nothing was moved, but the quarantine directory came and went: the parent changed. + Err(cause) => return Ok(Step::RolledBack(cause)), + Ok(false) => return Ok(Step::Changed(RemovalOutcome::Vanished)), + Ok(true) => {} } match self.judge(observer) { Err(cause) => match self.restore() { Ok(()) => Ok(Step::RolledBack(cause)), - Err(_) => { - self.keep_the_entry(); - Ok(Step::Stranded(cause)) - } + Err(_) if self.retained() => Ok(Step::Stranded(cause)), + Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), }, step => step, } @@ -308,8 +312,8 @@ impl Removal<'_> { } Inside::Object(..) | Inside::Foreign => { let restored = self.restore().is_ok(); - if !restored { - self.keep_the_entry(); + if !restored && !self.retained() { + return Ok(Step::Changed(RemovalOutcome::Disturbed)); } Ok(Step::Changed(RemovalOutcome::Swapped { restored })) } @@ -337,10 +341,12 @@ impl Removal<'_> { } /// The entry stays in the quarantine directory: that directory changed as well as the parent, so - /// it is synced too (best effort; a failure here does not replace the outcome that says where the - /// entry is). - fn keep_the_entry(&self) { + /// it is synced too (best effort), and the outcome may only say where the entry is if the recorded + /// name still leads to the directory that holds it. If an actor renamed that directory away, the + /// entry cannot be found by name any more, and that is reported as tampering. + fn retained(&self) -> bool { let _ = self.quarantine.fd.sync_all(); + self.quarantine.is_named(&self.dir.handle) } } diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 837f41531..9c34a1a1e 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1759,7 +1759,11 @@ quarantine directory is removed by its name only if the name still leads to the actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine -directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent. +directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and +only reported as being there if the recorded name still leads to that directory; if an actor renamed it away the outcome is +`Disturbed` (tampered, the entry cannot be found by name). A failure to move the entry at all (`Unsupported`, an error from +`renameat2`) also syncs the parent, since the quarantine directory came and went. A umask that takes the owner's read access +away leaves a quarantine directory that cannot be opened: the removal is refused (`PermissionDenied`), fail closed. **The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the From 586f66385cda29c80acdcc5315a794c0c47bceb9 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:30:39 +0200 Subject: [PATCH 08/13] refactor(core): move the hardening of the Unix file removal into its own part (#445) The Unix file removal had grown to 1,306 meaningful lines through review waves, over the 1,200-line tier. Its hardening against a same-user actor in the quarantine directory and the durability of the failure paths (sync after a rollback or a stranded entry, an identity-checked cleanup of the quarantine directory, a quarantine that was renamed away, an unopenable one, the creation race stated) is a separate proof boundary and is re-applied as the next part on top of this one. What stays here is the removal itself with its single rollback point, the Stranded outcome, the sync of changes that are made, the forced swap tests and the link-count window. The root-skip of the permission case stays. --- .../src/pinned/removal.rs | 10 +- .../src/pinned/tests/remove_unix.rs | 153 ++---------------- .../src/pinned/unix/removal.rs | 78 ++------- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 20 +-- 4 files changed, 36 insertions(+), 225 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 0525cf7b2..14299abaf 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -63,12 +63,10 @@ pub enum RemovalOutcome { /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine /// directory (`.h1q-*`, never removed by a sweep). Swapped { restored: bool }, - /// Unix only: the quarantine directory was tampered with. Either the authenticated object had not - /// lost a name after the unlink, so the name was bound to another object when it was unlinked (see - /// the window in the module documentation), and that object is gone; or the entry stayed in the - /// quarantine directory but an actor renamed that directory away, so the entry cannot be found by - /// the name that was recorded. Nothing can be undone: the caller must treat the directory as - /// tampered with, and never count this as a success. + /// Unix only: after the unlink the authenticated object had not lost a name, so the name was bound + /// to another object when it was unlinked (see the window in the module documentation). That + /// object is gone and nothing can be undone: the caller must treat the directory as tampered with, + /// and never count this as a success. Disturbed, /// Unix only: an error happened after the entry was moved into the quarantine directory and it /// could not be put back (`cause` is the error that started it). The entry is in the quarantine diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 17bd660a9..baffda71e 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -388,31 +388,24 @@ fn a_name_taken_after_the_move_keeps_the_new_file_and_a_vanished_name_is_reporte }), ); let gone = fixture("vanish"); - let mut synced = false; - let vanished = - gone.pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - match at(stage) { - (At::Checked, _) => fs::remove_file(gone.root.join("a")).unwrap(), - // The quarantine directory came and went, which changed the parent. - (At::BeforeSync, _) => synced = true, - _ => {} - } - Ok(()) - }); + let vanished = gone.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Checked, |_| { + fs::remove_file(gone.root.join("a")).unwrap() + }), + ); assert_eq!( ( after, fs::read(taken.root.join("a")).unwrap(), vanished, - synced, names(&gone.pinned) ), ( removed(), b"new".to_vec(), Ok(RemovalOutcome::Vanished), - true, Vec::::new() ) ); @@ -471,35 +464,24 @@ fn the_quarantine_directory_is_private_usable_under_any_umask_and_gone_afterward } #[test] -fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found_and_syncs_it_after_a_move() { +fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found() { let outcomes: Vec<_> = [At::Checked, At::MovedAside, At::Identified] .into_iter() .map(|here| { let f = fixture("rollback"); - let synced = std::cell::Cell::new(false); - let outcome = - f.pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - if at(stage).0 == At::BeforeSync { - synced.set(true); - return Ok(()); - } - failing_at(here)(stage) - }); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut failing_at(here), + ); ( outcome, fs::read(f.root.join("a")).unwrap(), quarantines(&f.root).len(), - synced.get(), ) }) .collect(); - let rolled_back = |synced| (Err(denied()), b"alpha".to_vec(), 0, synced); - // Nothing moved before the check, so there is nothing to sync; after the move the directory changed. - assert_eq!( - outcomes, - vec![rolled_back(false), rolled_back(true), rolled_back(true)] - ); + assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 3]); } #[test] @@ -551,9 +533,9 @@ fn lock_the_quarantine(quarantined: Option<&Path>) { } #[test] -fn a_search_permission_lost_after_the_move_is_reported_as_stranded() { +fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() { if rustix::process::geteuid().is_root() { - // Root is not kept out by permissions; the same logic is covered without them by the next test. + // Root is not kept out by permissions; the case is about the permission failure itself. eprintln!("skipped: permissions do not apply to root"); return; } @@ -578,106 +560,3 @@ fn a_search_permission_lost_after_the_move_is_reported_as_stranded() { ) ); } - -#[test] -fn an_error_after_the_move_whose_put_back_is_blocked_is_reported_as_stranded_whoever_runs_it() { - let f = fixture("stranded-injected"); - let original = ino(&f.root.join("a")); - let mut synced = false; - let outcome = f - .pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - match at(stage) { - // The name is taken again and the observer fails: the entry cannot go back. - (At::MovedAside, _) => { - fs::write(f.root.join("a"), b"taken").unwrap(); - return Err(denied()); - } - (At::BeforeSync, _) => synced = true, - _ => {} - } - Ok(()) - }); - let left = quarantines(&f.root); - assert_eq!( - ( - outcome, - fs::read(f.root.join("a")).unwrap(), - left.len(), - ino(&left[0].join(INSIDE)) == original, - synced - ), - ( - Ok(RemovalOutcome::Stranded { cause: denied() }), - b"taken".to_vec(), - 1, - true, - true - ) - ); -} - -#[test] -fn a_quarantine_name_that_leads_somewhere_else_at_the_end_is_not_removed() { - // The held directory is renamed away and an empty directory of the actor takes its name: the - // removal goes on in the directory it holds and leaves the actor's directory alone. - let f = fixture("discard"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::MovedAside, |quarantined| { - let directory = quarantined.unwrap().parent().unwrap(); - fs::rename(directory, f.aside.join("held")).unwrap(); - fs::create_dir(directory).unwrap(); - }), - ); - let held = fs::read_dir(f.aside.join("held")).unwrap().count(); - assert_eq!( - (outcome, held, quarantines(&f.root).len()), - (removed(), 0, 1) - ); -} - -#[test] -fn a_quarantine_directory_the_owner_cannot_open_is_refused_and_a_renamed_one_is_reported() { - if rustix::process::geteuid().is_root() { - eprintln!("skipped: permissions do not apply to root"); - return; - } - // A umask that takes the owner's read access away leaves a directory that cannot be opened: the - // removal is refused (fail closed) and nothing of the store is touched. - let locked = fixture("owner-masked"); - let name = format!("{QUARANTINE_PREFIX}{}", "b".repeat(32)); - fs::create_dir(locked.root.join(&name)).unwrap(); - fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o000)).unwrap(); - let refused = make_usable(&locked.pinned.handle, name.as_ref()); - fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o700)).unwrap(); - // The held quarantine directory is renamed away and the name taken again while the observer fails: - // the entry cannot go back and cannot be found by the recorded name either. - let f = fixture("renamed-quarantine"); - let outcome = f - .pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - if let (At::MovedAside, Some(inside)) = at(stage) { - let directory = inside.parent().unwrap(); - fs::rename(directory, f.aside.join("held")).unwrap(); - fs::write(f.root.join("a"), b"taken").unwrap(); - return Err(denied()); - } - Ok(()) - }); - assert_eq!( - ( - refused, - outcome, - fs::read(f.aside.join("held").join(INSIDE)).unwrap(), - fs::read(f.root.join("a")).unwrap() - ), - ( - Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)), - Ok(RemovalOutcome::Disturbed), - b"alpha".to_vec(), - b"taken".to_vec() - ) - ); -} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 8840da6f7..88333ca43 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -14,9 +14,7 @@ use rustix::fs::{ use rustix::io::Errno; use rustix::process::geteuid; -use super::{ - dir_flags, file_flags, identity_from, io_error, kind_of, stat_child, widen, DirHandle, -}; +use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; use crate::durable::{DirectoryDurability, EntryKind}; use crate::pinned::{ ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, @@ -32,8 +30,6 @@ pub(in crate::pinned) const INSIDE: &str = "x"; struct Quarantine { fd: File, name: OsString, - /// The directory that was created, to tell it from whatever the name leads to later. - identity: ObjectIdentity, } /// What is bound to the quarantine name right now. @@ -48,15 +44,13 @@ enum Inside { /// How a removal ended before the parent directory was synced. enum Step { + /// Nothing in the directory changed. + Unchanged(RemovalOutcome), /// The directory changed (an entry was moved and put back, or a quarantine directory came and /// went); the outcome is final. Changed(RemovalOutcome), /// The object lost its name. Unlinked, - /// The entry was moved aside and put back; this is why the removal stopped. - RolledBack(PinnedError), - /// The entry could not be put back and stays in the quarantine directory. - Stranded(PinnedError), } impl Quarantine { @@ -68,8 +62,8 @@ impl Quarantine { let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; - match Self::made_usable(parent, &name).and_then(|fd| Self::held(fd, name.clone())) { - Ok(held) => Ok(held), + match Self::made_usable(parent, &name) { + Ok(fd) => Ok(Quarantine { fd, name }), Err(error) => { let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); Err(error) @@ -77,11 +71,6 @@ impl Quarantine { } } - fn held(fd: File, name: OsString) -> Result { - let identity = identity_from(&fstat(&fd).map_err(io_error)?)?; - Ok(Quarantine { fd, name, identity }) - } - /// The directory just created, with the owner's access set explicitly (a restrictive umask would /// otherwise leave a directory that nothing can be moved into) and checked. fn made_usable(parent: &DirHandle, name: &OsStr) -> Result { @@ -170,22 +159,9 @@ impl Quarantine { parent_path.join(&self.name).join(INSIDE) } - /// Removes this directory if it is empty. A directory that still holds an entry is left, and so is - /// whatever the name leads to if that is no longer the directory that was created (the name is all - /// there is to remove it by; what is removed is at most an empty directory). + /// Removes this directory if it is empty. A directory that still holds an entry is left. fn discard(&self, parent: &DirHandle) { - if self.is_named(parent) { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); - } - } - - /// Whether the name still leads to the directory that was created. - fn is_named(&self, parent: &DirHandle) -> bool { - stat_child(parent, &self.name) - .ok() - .flatten() - .and_then(|stat| identity_from(&stat).ok()) - .is_some_and(|found| found == self.identity) + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); } } @@ -284,17 +260,13 @@ impl Removal<'_> { /// the move puts the entry back first; if that is impossible the removal says so /// ([`RemovalOutcome::Stranded`]) instead of reporting an error that hides where the entry is. fn detach(&self, observer: Observer<'_>) -> Result { - match self.quarantine.adopt(&self.dir.handle, self.target.name) { - // Nothing was moved, but the quarantine directory came and went: the parent changed. - Err(cause) => return Ok(Step::RolledBack(cause)), - Ok(false) => return Ok(Step::Changed(RemovalOutcome::Vanished)), - Ok(true) => {} + if !self.quarantine.adopt(&self.dir.handle, self.target.name)? { + return Ok(Step::Unchanged(RemovalOutcome::Vanished)); } match self.judge(observer) { Err(cause) => match self.restore() { - Ok(()) => Ok(Step::RolledBack(cause)), - Err(_) if self.retained() => Ok(Step::Stranded(cause)), - Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), + Ok(()) => Err(cause), + Err(_) => Ok(Step::Unchanged(RemovalOutcome::Stranded { cause })), }, step => step, } @@ -312,9 +284,6 @@ impl Removal<'_> { } Inside::Object(..) | Inside::Foreign => { let restored = self.restore().is_ok(); - if !restored && !self.retained() { - return Ok(Step::Changed(RemovalOutcome::Disturbed)); - } Ok(Step::Changed(RemovalOutcome::Swapped { restored })) } } @@ -339,15 +308,6 @@ impl Removal<'_> { fn restore(&self) -> Result<(), PinnedError> { self.quarantine.restore(&self.dir.handle, self.target.name) } - - /// The entry stays in the quarantine directory: that directory changed as well as the parent, so - /// it is synced too (best effort), and the outcome may only say where the entry is if the recorded - /// name still leads to the directory that holds it. If an actor renamed that directory away, the - /// entry cannot be found by name any more, and that is reported as tampering. - fn retained(&self) -> bool { - let _ = self.quarantine.fd.sync_all(); - self.quarantine.is_named(&self.dir.handle) - } } fn unlinked(outcome: RemovalOutcome) -> Step { @@ -366,14 +326,7 @@ fn settle( observer: Observer<'_>, ) -> Result { match step { - Step::RolledBack(cause) => { - sync_after_failure(dir, observer); - Err(cause) - } - Step::Stranded(cause) => { - sync_after_failure(dir, observer); - Ok(RemovalOutcome::Stranded { cause }) - } + Step::Unchanged(outcome) => Ok(outcome), Step::Changed(outcome) => { observer(RemovalStage::BeforeSync)?; dir.sync()?; @@ -404,10 +357,3 @@ pub(in crate::pinned) fn remove_leftover( ) -> Result { Err(PinnedError::Unsupported) } - -/// After a failure that moved an entry aside and back (or left it aside) the directory has changed too: -/// the sync is attempted, and its own failure does not replace the failure that is being reported. -fn sync_after_failure(dir: &PinnedDir, observer: Observer<'_>) { - let _ = observer(RemovalStage::BeforeSync); - let _ = dir.sync(); -} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 9c34a1a1e..c20255dc8 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1751,19 +1751,9 @@ quarantine directory; anything else (a live file, a link, a directory, a file th never unlinked. A file system without `RENAME_NOREPLACE` answers `Unsupported` before anything is touched (not exercised on the hosted runners). **Every failure after the move is handled at one point:** the entry is put back and the error is returned, or, if it cannot be put back, the outcome is `Stranded { cause }` (the entry is in the quarantine directory as `x`, -nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed: after -a removal and after a put-back as a step of the removal (a sync that fails is an error although the entry is gone or back, and the -caller repeats `PinnedDir::sync`; a rerun of the removal finds nothing to do and would not sync), and after a rolled-back or -stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). The -quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an -actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no -descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an -actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine -directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and -only reported as being there if the recorded name still leads to that directory; if an actor renamed it away the outcome is -`Disturbed` (tampered, the entry cannot be found by name). A failure to move the entry at all (`Unsupported`, an error from -`renameat2`) also syncs the parent, since the quarantine directory came and went. A umask that takes the owner's read access -away leaves a quarantine directory that cannot be opened: the removal is refused (`PermissionDenied`), fail closed. +nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed; a +sync that fails is an error although the entry is gone or back, and the caller repeats `PinnedDir::sync` (a rerun of the +removal finds nothing to do and would not sync). **The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the @@ -1783,9 +1773,7 @@ directory (goes back) and a hard link (object alive under the other name); a nam renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported for a removal and for a put-back and repeated by `sync`; a quarantine directory that loses its search permission after the -move gives `Stranded` with the entry still the authenticated inode, and the same case without permissions (an observer that -fails while the name is taken again, so it runs the same for root); a quarantine directory renamed away and replaced by an empty -directory of the actor is not removed. Mutation-checked: skipping the identity comparison fails +move gives `Stranded` with the entry still the authenticated inode. Mutation-checked: skipping the identity comparison fails five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a failed inspection fails the `Stranded` case. From 30882e50126ade97e14ecbf4c4da23b6d259d243 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:39:40 +0200 Subject: [PATCH 09/13] feat(core): harden the Unix file removal against a same-user actor in the quarantine directory (#445) The parent is synced after a rollback, a stranded entry, a put-back and a vanished name, and an entry that stays in the quarantine directory is retained with a best-effort sync of that directory too. The quarantine directory is removed by its name only if the name still leads to the directory that was created. An entry is reported as retained only if the recorded name still leads to the directory that holds it; otherwise the outcome is Disturbed. A quarantine directory that cannot be opened is refused. Tests force each case, including the quarantine renamed away and the put-back blocked, so they also run for root. --- CHANGELOG.md | 2 + .../src/pinned/removal.rs | 10 +- .../src/pinned/tests/remove_unix.rs | 153 ++++++++++++++++-- .../src/pinned/unix/removal.rs | 78 +++++++-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 20 ++- 5 files changed, 227 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a72b160b..b8026ba50 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:** 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; the quarantine directory is removed by its name only if the name still leads to the directory that was created; an entry is reported as retained only if the recorded name still leads to its directory, otherwise `Disturbed`; 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 #1072. + - **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. - **R-15 Gate 4:** the API and the isolation of the bound removal, `test-support` only (slice H1a-2, part 1 of 4). `PinnedDir::remove_authenticated_file`, `remove_empty_dir` and `remove_leftover_file` with `RemovalOutcome`, `RemovalStage` and `Observer` exist only with the test-only feature, have no caller and, in this part, answer `Unsupported` and remove nothing; the platform implementations follow in three stacked parts. A scan of the sources and a compile of a dependent with and without the feature keep the primitives out of every other build. `PinnedDir::sync` (no deletion) is added. The contract states what no Unix primitive can do (unlink "this inode": a same-user actor in the quarantine directory can win one race, which is detected after the fact as `Disturbed`, not prevented) and leaves the decision about that residual to the maintainer before any sweep uses it. No new `unsafe`, no new dependency feature, no new package. Issue #359. PR #1068. diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 14299abaf..0525cf7b2 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -63,10 +63,12 @@ pub enum RemovalOutcome { /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine /// directory (`.h1q-*`, never removed by a sweep). Swapped { restored: bool }, - /// Unix only: after the unlink the authenticated object had not lost a name, so the name was bound - /// to another object when it was unlinked (see the window in the module documentation). That - /// object is gone and nothing can be undone: the caller must treat the directory as tampered with, - /// and never count this as a success. + /// Unix only: the quarantine directory was tampered with. Either the authenticated object had not + /// lost a name after the unlink, so the name was bound to another object when it was unlinked (see + /// the window in the module documentation), and that object is gone; or the entry stayed in the + /// quarantine directory but an actor renamed that directory away, so the entry cannot be found by + /// the name that was recorded. Nothing can be undone: the caller must treat the directory as + /// tampered with, and never count this as a success. Disturbed, /// Unix only: an error happened after the entry was moved into the quarantine directory and it /// could not be put back (`cause` is the error that started it). The entry is in the quarantine diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index baffda71e..17bd660a9 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -388,24 +388,31 @@ fn a_name_taken_after_the_move_keeps_the_new_file_and_a_vanished_name_is_reporte }), ); let gone = fixture("vanish"); - let vanished = gone.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut on(At::Checked, |_| { - fs::remove_file(gone.root.join("a")).unwrap() - }), - ); + let mut synced = false; + let vanished = + gone.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + match at(stage) { + (At::Checked, _) => fs::remove_file(gone.root.join("a")).unwrap(), + // The quarantine directory came and went, which changed the parent. + (At::BeforeSync, _) => synced = true, + _ => {} + } + Ok(()) + }); assert_eq!( ( after, fs::read(taken.root.join("a")).unwrap(), vanished, + synced, names(&gone.pinned) ), ( removed(), b"new".to_vec(), Ok(RemovalOutcome::Vanished), + true, Vec::::new() ) ); @@ -464,24 +471,35 @@ fn the_quarantine_directory_is_private_usable_under_any_umask_and_gone_afterward } #[test] -fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found() { +fn a_failure_at_any_stage_leaves_the_directory_as_it_was_found_and_syncs_it_after_a_move() { let outcomes: Vec<_> = [At::Checked, At::MovedAside, At::Identified] .into_iter() .map(|here| { let f = fixture("rollback"); - let outcome = f.pinned.remove_authenticated_file_observed( - "a", - holds(b"alpha"), - &mut failing_at(here), - ); + let synced = std::cell::Cell::new(false); + let outcome = + f.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if at(stage).0 == At::BeforeSync { + synced.set(true); + return Ok(()); + } + failing_at(here)(stage) + }); ( outcome, fs::read(f.root.join("a")).unwrap(), quarantines(&f.root).len(), + synced.get(), ) }) .collect(); - assert_eq!(outcomes, vec![(Err(denied()), b"alpha".to_vec(), 0); 3]); + let rolled_back = |synced| (Err(denied()), b"alpha".to_vec(), 0, synced); + // Nothing moved before the check, so there is nothing to sync; after the move the directory changed. + assert_eq!( + outcomes, + vec![rolled_back(false), rolled_back(true), rolled_back(true)] + ); } #[test] @@ -533,9 +551,9 @@ fn lock_the_quarantine(quarantined: Option<&Path>) { } #[test] -fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() { +fn a_search_permission_lost_after_the_move_is_reported_as_stranded() { if rustix::process::geteuid().is_root() { - // Root is not kept out by permissions; the case is about the permission failure itself. + // Root is not kept out by permissions; the same logic is covered without them by the next test. eprintln!("skipped: permissions do not apply to root"); return; } @@ -560,3 +578,106 @@ fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded() ) ); } + +#[test] +fn an_error_after_the_move_whose_put_back_is_blocked_is_reported_as_stranded_whoever_runs_it() { + let f = fixture("stranded-injected"); + let original = ino(&f.root.join("a")); + let mut synced = false; + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + match at(stage) { + // The name is taken again and the observer fails: the entry cannot go back. + (At::MovedAside, _) => { + fs::write(f.root.join("a"), b"taken").unwrap(); + return Err(denied()); + } + (At::BeforeSync, _) => synced = true, + _ => {} + } + Ok(()) + }); + let left = quarantines(&f.root); + assert_eq!( + ( + outcome, + fs::read(f.root.join("a")).unwrap(), + left.len(), + ino(&left[0].join(INSIDE)) == original, + synced + ), + ( + Ok(RemovalOutcome::Stranded { cause: denied() }), + b"taken".to_vec(), + 1, + true, + true + ) + ); +} + +#[test] +fn a_quarantine_name_that_leads_somewhere_else_at_the_end_is_not_removed() { + // The held directory is renamed away and an empty directory of the actor takes its name: the + // removal goes on in the directory it holds and leaves the actor's directory alone. + let f = fixture("discard"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |quarantined| { + let directory = quarantined.unwrap().parent().unwrap(); + fs::rename(directory, f.aside.join("held")).unwrap(); + fs::create_dir(directory).unwrap(); + }), + ); + let held = fs::read_dir(f.aside.join("held")).unwrap().count(); + assert_eq!( + (outcome, held, quarantines(&f.root).len()), + (removed(), 0, 1) + ); +} + +#[test] +fn a_quarantine_directory_the_owner_cannot_open_is_refused_and_a_renamed_one_is_reported() { + if rustix::process::geteuid().is_root() { + eprintln!("skipped: permissions do not apply to root"); + return; + } + // A umask that takes the owner's read access away leaves a directory that cannot be opened: the + // removal is refused (fail closed) and nothing of the store is touched. + let locked = fixture("owner-masked"); + let name = format!("{QUARANTINE_PREFIX}{}", "b".repeat(32)); + fs::create_dir(locked.root.join(&name)).unwrap(); + fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o000)).unwrap(); + let refused = make_usable(&locked.pinned.handle, name.as_ref()); + fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o700)).unwrap(); + // The held quarantine directory is renamed away and the name taken again while the observer fails: + // the entry cannot go back and cannot be found by the recorded name either. + let f = fixture("renamed-quarantine"); + let outcome = f + .pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if let (At::MovedAside, Some(inside)) = at(stage) { + let directory = inside.parent().unwrap(); + fs::rename(directory, f.aside.join("held")).unwrap(); + fs::write(f.root.join("a"), b"taken").unwrap(); + return Err(denied()); + } + Ok(()) + }); + assert_eq!( + ( + refused, + outcome, + fs::read(f.aside.join("held").join(INSIDE)).unwrap(), + fs::read(f.root.join("a")).unwrap() + ), + ( + Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)), + Ok(RemovalOutcome::Disturbed), + b"alpha".to_vec(), + b"taken".to_vec() + ) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 88333ca43..8840da6f7 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -14,7 +14,9 @@ use rustix::fs::{ use rustix::io::Errno; use rustix::process::geteuid; -use super::{dir_flags, file_flags, io_error, kind_of, stat_child, widen, DirHandle}; +use super::{ + dir_flags, file_flags, identity_from, io_error, kind_of, stat_child, widen, DirHandle, +}; use crate::durable::{DirectoryDurability, EntryKind}; use crate::pinned::{ ObjectIdentity, Observer, PinnedDir, PinnedError, PinnedFile, RemovalOutcome, RemovalStage, @@ -30,6 +32,8 @@ pub(in crate::pinned) const INSIDE: &str = "x"; struct Quarantine { fd: File, name: OsString, + /// The directory that was created, to tell it from whatever the name leads to later. + identity: ObjectIdentity, } /// What is bound to the quarantine name right now. @@ -44,13 +48,15 @@ enum Inside { /// How a removal ended before the parent directory was synced. enum Step { - /// Nothing in the directory changed. - Unchanged(RemovalOutcome), /// The directory changed (an entry was moved and put back, or a quarantine directory came and /// went); the outcome is final. Changed(RemovalOutcome), /// The object lost its name. Unlinked, + /// The entry was moved aside and put back; this is why the removal stopped. + RolledBack(PinnedError), + /// The entry could not be put back and stays in the quarantine directory. + Stranded(PinnedError), } impl Quarantine { @@ -62,8 +68,8 @@ impl Quarantine { let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; - match Self::made_usable(parent, &name) { - Ok(fd) => Ok(Quarantine { fd, name }), + match Self::made_usable(parent, &name).and_then(|fd| Self::held(fd, name.clone())) { + Ok(held) => Ok(held), Err(error) => { let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); Err(error) @@ -71,6 +77,11 @@ impl Quarantine { } } + fn held(fd: File, name: OsString) -> Result { + let identity = identity_from(&fstat(&fd).map_err(io_error)?)?; + Ok(Quarantine { fd, name, identity }) + } + /// The directory just created, with the owner's access set explicitly (a restrictive umask would /// otherwise leave a directory that nothing can be moved into) and checked. fn made_usable(parent: &DirHandle, name: &OsStr) -> Result { @@ -159,9 +170,22 @@ impl Quarantine { parent_path.join(&self.name).join(INSIDE) } - /// Removes this directory if it is empty. A directory that still holds an entry is left. + /// Removes this directory if it is empty. A directory that still holds an entry is left, and so is + /// whatever the name leads to if that is no longer the directory that was created (the name is all + /// there is to remove it by; what is removed is at most an empty directory). fn discard(&self, parent: &DirHandle) { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + if self.is_named(parent) { + let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); + } + } + + /// Whether the name still leads to the directory that was created. + fn is_named(&self, parent: &DirHandle) -> bool { + stat_child(parent, &self.name) + .ok() + .flatten() + .and_then(|stat| identity_from(&stat).ok()) + .is_some_and(|found| found == self.identity) } } @@ -260,13 +284,17 @@ impl Removal<'_> { /// the move puts the entry back first; if that is impossible the removal says so /// ([`RemovalOutcome::Stranded`]) instead of reporting an error that hides where the entry is. fn detach(&self, observer: Observer<'_>) -> Result { - if !self.quarantine.adopt(&self.dir.handle, self.target.name)? { - return Ok(Step::Unchanged(RemovalOutcome::Vanished)); + match self.quarantine.adopt(&self.dir.handle, self.target.name) { + // Nothing was moved, but the quarantine directory came and went: the parent changed. + Err(cause) => return Ok(Step::RolledBack(cause)), + Ok(false) => return Ok(Step::Changed(RemovalOutcome::Vanished)), + Ok(true) => {} } match self.judge(observer) { Err(cause) => match self.restore() { - Ok(()) => Err(cause), - Err(_) => Ok(Step::Unchanged(RemovalOutcome::Stranded { cause })), + Ok(()) => Ok(Step::RolledBack(cause)), + Err(_) if self.retained() => Ok(Step::Stranded(cause)), + Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), }, step => step, } @@ -284,6 +312,9 @@ impl Removal<'_> { } Inside::Object(..) | Inside::Foreign => { let restored = self.restore().is_ok(); + if !restored && !self.retained() { + return Ok(Step::Changed(RemovalOutcome::Disturbed)); + } Ok(Step::Changed(RemovalOutcome::Swapped { restored })) } } @@ -308,6 +339,15 @@ impl Removal<'_> { fn restore(&self) -> Result<(), PinnedError> { self.quarantine.restore(&self.dir.handle, self.target.name) } + + /// The entry stays in the quarantine directory: that directory changed as well as the parent, so + /// it is synced too (best effort), and the outcome may only say where the entry is if the recorded + /// name still leads to the directory that holds it. If an actor renamed that directory away, the + /// entry cannot be found by name any more, and that is reported as tampering. + fn retained(&self) -> bool { + let _ = self.quarantine.fd.sync_all(); + self.quarantine.is_named(&self.dir.handle) + } } fn unlinked(outcome: RemovalOutcome) -> Step { @@ -326,7 +366,14 @@ fn settle( observer: Observer<'_>, ) -> Result { match step { - Step::Unchanged(outcome) => Ok(outcome), + Step::RolledBack(cause) => { + sync_after_failure(dir, observer); + Err(cause) + } + Step::Stranded(cause) => { + sync_after_failure(dir, observer); + Ok(RemovalOutcome::Stranded { cause }) + } Step::Changed(outcome) => { observer(RemovalStage::BeforeSync)?; dir.sync()?; @@ -357,3 +404,10 @@ pub(in crate::pinned) fn remove_leftover( ) -> Result { Err(PinnedError::Unsupported) } + +/// After a failure that moved an entry aside and back (or left it aside) the directory has changed too: +/// the sync is attempted, and its own failure does not replace the failure that is being reported. +fn sync_after_failure(dir: &PinnedDir, observer: Observer<'_>) { + let _ = observer(RemovalStage::BeforeSync); + let _ = dir.sync(); +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index c20255dc8..9c34a1a1e 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1751,9 +1751,19 @@ quarantine directory; anything else (a live file, a link, a directory, a file th never unlinked. A file system without `RENAME_NOREPLACE` answers `Unsupported` before anything is touched (not exercised on the hosted runners). **Every failure after the move is handled at one point:** the entry is put back and the error is returned, or, if it cannot be put back, the outcome is `Stranded { cause }` (the entry is in the quarantine directory as `x`, -nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed; a -sync that fails is an error although the entry is gone or back, and the caller repeats `PinnedDir::sync` (a rerun of the -removal finds nothing to do and would not sync). +nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed: after +a removal and after a put-back as a step of the removal (a sync that fails is an error although the entry is gone or back, and the +caller repeats `PinnedDir::sync`; a rerun of the removal finds nothing to do and would not sync), and after a rolled-back or +stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). The +quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an +actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no +descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an +actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine +directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and +only reported as being there if the recorded name still leads to that directory; if an actor renamed it away the outcome is +`Disturbed` (tampered, the entry cannot be found by name). A failure to move the entry at all (`Unsupported`, an error from +`renameat2`) also syncs the parent, since the quarantine directory came and went. A umask that takes the owner's read access +away leaves a quarantine directory that cannot be opened: the removal is refused (`PermissionDenied`), fail closed. **The window, stated.** The quarantine directory is not exclusive: another process of the same user can list the parent, find it and enter it. The design holds when it is manipulated at every point before the last identification (tests below). After the @@ -1773,7 +1783,9 @@ directory (goes back) and a hard link (object alive under the other name); a nam renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported for a removal and for a put-back and repeated by `sync`; a quarantine directory that loses its search permission after the -move gives `Stranded` with the entry still the authenticated inode. Mutation-checked: skipping the identity comparison fails +move gives `Stranded` with the entry still the authenticated inode, and the same case without permissions (an observer that +fails while the name is taken again, so it runs the same for root); a quarantine directory renamed away and replaced by an empty +directory of the actor is not removed. Mutation-checked: skipping the identity comparison fails five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a failed inspection fails the `Stranded` case. From fe7158ea30e7609d582d75edc136297fde78fb7d Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:37:10 +0200 Subject: [PATCH 10/13] fix(core): report a missing quarantine entry as disturbed, sync the held directory, narrow the cleanup claim (#445) Review findings on the hardening: - retained() checked the name of the quarantine directory but not that the entry is still inside; an actor that removed it and blocked the put-back got Stranded. A proven absence is now Disturbed (an error while looking is not a proof of absence). - When the held quarantine directory was renamed away, the removal goes on in it and now syncs it as well as the parent before reporting Removed. - A failure while constructing the quarantine directory synced nothing after removing it; it syncs the parent best effort. - The identity check before the name-based rmdir narrows the race and does not close it; code and documentation say so. - The permission-based tests skip for root only where the permission is the subject; the renamed-away and removed-entry cases run for every user. --- .../src/pinned/tests/remove_unix.rs | 99 +++++++++++++------ .../src/pinned/unix/removal.rs | 28 +++++- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 7 +- 3 files changed, 97 insertions(+), 37 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 17bd660a9..cb4331e4a 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -639,45 +639,84 @@ fn a_quarantine_name_that_leads_somewhere_else_at_the_end_is_not_removed() { } #[test] -fn a_quarantine_directory_the_owner_cannot_open_is_refused_and_a_renamed_one_is_reported() { +fn a_quarantine_directory_the_owner_cannot_open_is_refused_fail_closed() { if rustix::process::geteuid().is_root() { eprintln!("skipped: permissions do not apply to root"); return; } // A umask that takes the owner's read access away leaves a directory that cannot be opened: the - // removal is refused (fail closed) and nothing of the store is touched. - let locked = fixture("owner-masked"); + // removal is refused and nothing of the store is touched. + let f = fixture("owner-masked"); let name = format!("{QUARANTINE_PREFIX}{}", "b".repeat(32)); - fs::create_dir(locked.root.join(&name)).unwrap(); - fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o000)).unwrap(); - let refused = make_usable(&locked.pinned.handle, name.as_ref()); - fs::set_permissions(locked.root.join(&name), fs::Permissions::from_mode(0o700)).unwrap(); - // The held quarantine directory is renamed away and the name taken again while the observer fails: - // the entry cannot go back and cannot be found by the recorded name either. - let f = fixture("renamed-quarantine"); - let outcome = f - .pinned - .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { - if let (At::MovedAside, Some(inside)) = at(stage) { - let directory = inside.parent().unwrap(); - fs::rename(directory, f.aside.join("held")).unwrap(); - fs::write(f.root.join("a"), b"taken").unwrap(); - return Err(denied()); - } - Ok(()) - }); + fs::create_dir(f.root.join(&name)).unwrap(); + fs::set_permissions(f.root.join(&name), fs::Permissions::from_mode(0o000)).unwrap(); + let refused = make_usable(&f.pinned.handle, name.as_ref()); + fs::set_permissions(f.root.join(&name), fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + refused, + Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)) + ); +} + +/// What the actor does to the quarantine directory once the entry is inside, while the put-back is +/// blocked by taking the name again and the observer fails. +type Tamper = (&'static str, fn(&Path, &Path, &Path)); + +#[test] +fn an_entry_that_cannot_be_found_where_the_outcome_would_say_is_reported_as_disturbed() { + let tampers: [Tamper; 2] = [ + // The held directory is renamed away: the recorded name leads somewhere else. + ("renamed-away", |directory, _inside, aside| { + fs::rename(directory, aside.join("held")).unwrap(); + }), + // The entry itself is removed: the directory is still named, but holds nothing. + ("entry-removed", |_directory, inside, _aside| { + fs::remove_file(inside).unwrap(); + }), + ]; + let outcomes: Vec<_> = tampers + .into_iter() + .map(|(label, tamper)| { + let f = fixture(label); + let outcome = + f.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + if let (At::MovedAside, Some(inside)) = at(stage) { + tamper(inside.parent().unwrap(), inside, &f.aside); + fs::write(f.root.join("a"), b"taken").unwrap(); + return Err(denied()); + } + Ok(()) + }); + (label, outcome, fs::read(f.root.join("a")).unwrap()) + }) + .collect(); + let disturbed = |label| (label, Ok(RemovalOutcome::Disturbed), b"taken".to_vec()); + assert_eq!( + outcomes, + vec![disturbed("renamed-away"), disturbed("entry-removed")] + ); +} + +#[test] +fn a_quarantine_renamed_away_is_still_removed_through_the_held_directory_and_the_entry_is_gone() { + // The removal goes on in the directory it holds even if its name leads elsewhere; the entry is + // unlinked there, and that directory is synced as well as the parent. + let f = fixture("renamed-held"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::MovedAside, |inside| { + let directory = inside.unwrap().parent().unwrap(); + fs::rename(directory, f.aside.join("held")).unwrap(); + }), + ); assert_eq!( ( - refused, outcome, - fs::read(f.aside.join("held").join(INSIDE)).unwrap(), - fs::read(f.root.join("a")).unwrap() + f.aside.join("held").join(INSIDE).exists(), + f.root.join("a").exists() ), - ( - Err(PinnedError::Io(std::io::ErrorKind::PermissionDenied)), - Ok(RemovalOutcome::Disturbed), - b"alpha".to_vec(), - b"taken".to_vec() - ) + (removed(), false, false) ); } diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 8840da6f7..54670c4ec 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -71,7 +71,11 @@ impl Quarantine { match Self::made_usable(parent, &name).and_then(|fd| Self::held(fd, name.clone())) { Ok(held) => Ok(held), Err(error) => { - let _ = unlinkat(parent, &name, AtFlags::REMOVEDIR); + if unlinkat(parent, &name, AtFlags::REMOVEDIR).is_ok() { + // The directory came and went: sync the parent best effort, so the error does not + // leave an unsynced quarantine directory behind. + let _ = parent.sync_all(); + } Err(error) } } @@ -171,14 +175,21 @@ impl Quarantine { } /// Removes this directory if it is empty. A directory that still holds an entry is left, and so is - /// whatever the name leads to if that is no longer the directory that was created (the name is all - /// there is to remove it by; what is removed is at most an empty directory). + /// whatever the name leads to if that is no longer the directory that was created. The check narrows + /// the race and does not close it (`rmdir` takes a name, not a descriptor): what a lost race can + /// remove is at most an empty directory. fn discard(&self, parent: &DirHandle) { if self.is_named(parent) { let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); } } + /// Whether the entry may still be inside: `false` only if it is known to be gone (an actor removed + /// it); an error while looking (a permission lost) is not a proof that it is gone. + fn holds_entry(&self) -> bool { + !matches!(stat_child(&self.fd, OsStr::new(INSIDE)), Ok(None)) + } + /// Whether the name still leads to the directory that was created. fn is_named(&self, parent: &DirHandle) -> bool { stat_child(parent, &self.name) @@ -332,7 +343,14 @@ impl Removal<'_> { self.restore()?; Ok(Step::Changed(RemovalOutcome::NotEmpty)) } - outcome => Ok(unlinked(outcome)), + outcome => { + // The unlink changed the directory that is held; if an actor renamed it away it is not + // the one the parent sync covers, so it is synced as well (best effort). + if !self.quarantine.is_named(&self.dir.handle) { + let _ = self.quarantine.fd.sync_all(); + } + Ok(unlinked(outcome)) + } } } @@ -346,7 +364,7 @@ impl Removal<'_> { /// entry cannot be found by name any more, and that is reported as tampering. fn retained(&self) -> bool { let _ = self.quarantine.fd.sync_all(); - self.quarantine.is_named(&self.dir.handle) + self.quarantine.is_named(&self.dir.handle) && self.quarantine.holds_entry() } } diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 9c34a1a1e..ed4f35701 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1760,8 +1760,11 @@ actor put at that name is left alone (what could be removed is at most an empty descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and -only reported as being there if the recorded name still leads to that directory; if an actor renamed it away the outcome is -`Disturbed` (tampered, the entry cannot be found by name). A failure to move the entry at all (`Unsupported`, an error from +only reported as being there if the recorded name still leads to that directory and the entry is not known to be gone; if an +actor renamed the directory away or removed the entry the outcome is `Disturbed` (tampered, the entry cannot be found by name). +If the held directory was renamed away but the removal went on in it, that directory is synced too. The identity check before the +name-based `rmdir` of the quarantine directory narrows the race and does not close it: a lost race removes at most an empty +directory. A failure to move the entry at all (`Unsupported`, an error from `renameat2`) also syncs the parent, since the quarantine directory came and went. A umask that takes the owner's read access away leaves a quarantine directory that cannot be opened: the removal is refused (`PermissionDenied`), fail closed. From e39e83773c911d3a61bcb86643f35c72cfdbdffc Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:53:29 +0200 Subject: [PATCH 11/13] fix(core): check the identity of a retained entry, sync the held quarantine directory, document the disturbed cases (#445) Review findings on the hardening: - retained() accepted any object at the recorded entry name, so an actor that moved the authenticated entry away and put another object there while blocking the put-back got Stranded. The entry must now still be the authenticated object (for a swapped-in object: the identity it was seen with); a proven absence or another object is Disturbed, and an error while looking proves neither. - The held quarantine directory is synced after the unlink in every case (removed afterwards, renamed away, or kept alive by an entry an actor put into it); a sync that fails makes the removal Removed(NotConfirmed) instead of confirmed. - The documentation of Disturbed names the removed and the replaced entry. --- .../src/pinned/removal.rs | 12 +-- .../src/pinned/tests/remove_unix.rs | 55 +++++++++++- .../src/pinned/unix/removal.rs | 85 +++++++++++++------ .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 10 ++- 4 files changed, 122 insertions(+), 40 deletions(-) diff --git a/crates/worldscript-secure-storage/src/pinned/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 0525cf7b2..7d2c59db3 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -51,7 +51,8 @@ pub const QUARANTINE_PREFIX: &str = ".h1q-"; pub enum RemovalOutcome { /// The authenticated object lost this name (on Unix as far as the check after the unlink can tell, /// see [`Disturbed`](Self::Disturbed)); the durability of the directory entry change as far as the - /// platform can confirm it. + /// platform can confirm it. On Unix that covers the directory and the quarantine directory the + /// object was unlinked from: if the latter could not be synced the removal is `NotConfirmed`. Removed(DirectoryDurability), /// The authentication said no; nothing was changed. Refused, @@ -65,10 +66,11 @@ pub enum RemovalOutcome { Swapped { restored: bool }, /// Unix only: the quarantine directory was tampered with. Either the authenticated object had not /// lost a name after the unlink, so the name was bound to another object when it was unlinked (see - /// the window in the module documentation), and that object is gone; or the entry stayed in the - /// quarantine directory but an actor renamed that directory away, so the entry cannot be found by - /// the name that was recorded. Nothing can be undone: the caller must treat the directory as - /// tampered with, and never count this as a success. + /// the window in the module documentation), and that object is gone; or the entry could not be put + /// back and is not where the outcome would have said: an actor renamed the quarantine directory + /// away, removed the entry, or replaced it with another object, so the authenticated object cannot + /// be found by the name that was recorded. Nothing can be undone: the caller must treat the + /// directory as tampered with, and never count this as a success. Disturbed, /// Unix only: an error happened after the entry was moved into the quarantine directory and it /// could not be put back (`cause` is the error that started it). The entry is in the quarantine diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index cb4331e4a..7056e2e4a 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -14,7 +14,7 @@ use rustix::fs::{unlinkat, AtFlags}; use super::remove::{holds, names, removed}; use super::Scratch; use crate::durable::DirectoryDurability; -use crate::pinned::unix::removal::{make_usable, INSIDE}; +use crate::pinned::unix::removal::{both_confirmed, make_usable, INSIDE}; use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; /// A pinned directory holding `a` = "alpha", with the paths a swap needs. @@ -663,8 +663,8 @@ fn a_quarantine_directory_the_owner_cannot_open_is_refused_fail_closed() { type Tamper = (&'static str, fn(&Path, &Path, &Path)); #[test] -fn an_entry_that_cannot_be_found_where_the_outcome_would_say_is_reported_as_disturbed() { - let tampers: [Tamper; 2] = [ +fn an_entry_that_is_not_where_the_outcome_would_say_is_reported_as_disturbed() { + let tampers: [Tamper; 3] = [ // The held directory is renamed away: the recorded name leads somewhere else. ("renamed-away", |directory, _inside, aside| { fs::rename(directory, aside.join("held")).unwrap(); @@ -673,6 +673,12 @@ fn an_entry_that_cannot_be_found_where_the_outcome_would_say_is_reported_as_dist ("entry-removed", |_directory, inside, _aside| { fs::remove_file(inside).unwrap(); }), + // The entry is moved away and another object takes its place: something is there, not the + // authenticated object. + ("entry-replaced", |_directory, inside, aside| { + fs::rename(inside, aside.join("authenticated")).unwrap(); + fs::write(inside, b"foreign").unwrap(); + }), ]; let outcomes: Vec<_> = tampers .into_iter() @@ -694,7 +700,48 @@ fn an_entry_that_cannot_be_found_where_the_outcome_would_say_is_reported_as_dist let disturbed = |label| (label, Ok(RemovalOutcome::Disturbed), b"taken".to_vec()); assert_eq!( outcomes, - vec![disturbed("renamed-away"), disturbed("entry-removed")] + vec![ + disturbed("renamed-away"), + disturbed("entry-removed"), + disturbed("entry-replaced") + ] + ); +} + +#[test] +fn a_quarantine_directory_an_actor_put_an_entry_into_survives_and_the_removal_still_completes() { + // The directory cannot be removed while it holds the actor's entry; the authenticated file is gone + // all the same, and the actor's entry is untouched. + let f = fixture("occupied-quarantine"); + let outcome = f.pinned.remove_authenticated_file_observed( + "a", + holds(b"alpha"), + &mut on(At::Identified, |inside| { + let directory = inside.unwrap().parent().unwrap(); + fs::write(directory.join("other"), b"foreign").unwrap(); + }), + ); + let left = quarantines(&f.root); + assert_eq!( + (outcome, f.root.join("a").exists(), left.len()), + (removed(), false, 1) + ); + assert_eq!(fs::read(left[0].join("other")).unwrap(), b"foreign"); + assert!(!left[0].join(INSIDE).exists()); +} + +#[test] +fn a_removal_is_confirmed_only_if_both_directories_were_synced() { + use DirectoryDurability::{Confirmed, NotConfirmed}; + let all = [ + (Confirmed, Confirmed), + (Confirmed, NotConfirmed), + (NotConfirmed, Confirmed), + (NotConfirmed, NotConfirmed), + ]; + assert_eq!( + all.map(|(parent, held)| both_confirmed(parent, held)), + [Confirmed, NotConfirmed, NotConfirmed, NotConfirmed] ); } diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 54670c4ec..bd9584e26 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -51,8 +51,8 @@ enum Step { /// The directory changed (an entry was moved and put back, or a quarantine directory came and /// went); the outcome is final. Changed(RemovalOutcome), - /// The object lost its name. - Unlinked, + /// The object lost its name; whether the quarantine directory it was unlinked from was synced. + Unlinked(DirectoryDurability), /// The entry was moved aside and put back; this is why the removal stopped. RolledBack(PinnedError), /// The entry could not be put back and stays in the quarantine directory. @@ -184,10 +184,18 @@ impl Quarantine { } } - /// Whether the entry may still be inside: `false` only if it is known to be gone (an actor removed - /// it); an error while looking (a permission lost) is not a proof that it is gone. - fn holds_entry(&self) -> bool { - !matches!(stat_child(&self.fd, OsStr::new(INSIDE)), Ok(None)) + /// Whether the entry may still be inside: `false` only if it is known to be gone, or to be another + /// object than `expected` (an actor removed or replaced it); an error while looking (a permission + /// lost) proves neither. Without an expectation any object counts. + fn holds(&self, expected: Option) -> bool { + match stat_child(&self.fd, OsStr::new(INSIDE)) { + Ok(None) => false, + Ok(Some(stat)) => match expected { + None => true, + Some(wanted) => identity_from(&stat).is_ok_and(|found| found == wanted), + }, + Err(_) => true, + } } /// Whether the name still leads to the directory that was created. @@ -207,6 +215,18 @@ pub(in crate::pinned) fn make_usable(parent: &DirHandle, name: &OsStr) -> Result Quarantine::made_usable(parent, name).map(|_| ()) } +/// The durability of a removal that touched two directories: confirmed only if both were synced. +pub(in crate::pinned) fn both_confirmed( + first: DirectoryDurability, + second: DirectoryDurability, +) -> DirectoryDurability { + if first == DirectoryDurability::Confirmed && second == DirectoryDurability::Confirmed { + DirectoryDurability::Confirmed + } else { + DirectoryDurability::NotConfirmed + } +} + /// Unlinks the entry that was identified in the quarantine directory and asks the open object whether /// it was the one that lost a name: its link count must have dropped. That is a check after the fact /// and a heuristic, not a proof of identity (see the residual in the parent module): an actor that also @@ -304,7 +324,7 @@ impl Removal<'_> { match self.judge(observer) { Err(cause) => match self.restore() { Ok(()) => Ok(Step::RolledBack(cause)), - Err(_) if self.retained() => Ok(Step::Stranded(cause)), + Err(_) if self.retained(Some(self.target.expected)) => Ok(Step::Stranded(cause)), Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), }, step => step, @@ -321,14 +341,19 @@ impl Removal<'_> { Inside::Object(object, found) if found == self.target.expected => { self.conclude(&object, observer) } - Inside::Object(..) | Inside::Foreign => { - let restored = self.restore().is_ok(); - if !restored && !self.retained() { - return Ok(Step::Changed(RemovalOutcome::Disturbed)); - } - Ok(Step::Changed(RemovalOutcome::Swapped { restored })) - } + Inside::Object(_, found) => self.swapped(Some(found)), + Inside::Foreign => self.swapped(None), + } + } + + /// What arrived is not the authenticated object: it goes back under its name, or stays aside if + /// that is impossible (and is then still `arrived`, the identity it was seen with if it was opened). + fn swapped(&self, arrived: Option) -> Result { + let restored = self.restore().is_ok(); + if !restored && !self.retained(arrived) { + return Ok(Step::Changed(RemovalOutcome::Disturbed)); } + Ok(Step::Changed(RemovalOutcome::Swapped { restored })) } /// The last window: the entry in the quarantine directory is the authenticated object. @@ -344,12 +369,15 @@ impl Removal<'_> { Ok(Step::Changed(RemovalOutcome::NotEmpty)) } outcome => { - // The unlink changed the directory that is held; if an actor renamed it away it is not - // the one the parent sync covers, so it is synced as well (best effort). - if !self.quarantine.is_named(&self.dir.handle) { - let _ = self.quarantine.fd.sync_all(); - } - Ok(unlinked(outcome)) + // The unlink changed the directory that is held, and that directory can outlive the + // removal (an actor renamed it away, or put an entry into it so that it cannot be + // removed). The parent sync does not cover it, so it is synced here, and a sync that + // fails leaves the removal unconfirmed. + let held = match self.quarantine.fd.sync_all() { + Ok(()) => DirectoryDurability::Confirmed, + Err(_) => DirectoryDurability::NotConfirmed, + }; + Ok(unlinked(outcome, held)) } } } @@ -360,17 +388,18 @@ impl Removal<'_> { /// The entry stays in the quarantine directory: that directory changed as well as the parent, so /// it is synced too (best effort), and the outcome may only say where the entry is if the recorded - /// name still leads to the directory that holds it. If an actor renamed that directory away, the - /// entry cannot be found by name any more, and that is reported as tampering. - fn retained(&self) -> bool { + /// name still leads to the directory that holds it and the entry in it is still `expected` (or + /// cannot be looked at). If an actor renamed that directory away, or removed or replaced the entry, + /// the entry cannot be found where the outcome would say, and that is reported as tampering. + fn retained(&self, expected: Option) -> bool { let _ = self.quarantine.fd.sync_all(); - self.quarantine.is_named(&self.dir.handle) && self.quarantine.holds_entry() + self.quarantine.is_named(&self.dir.handle) && self.quarantine.holds(expected) } } -fn unlinked(outcome: RemovalOutcome) -> Step { +fn unlinked(outcome: RemovalOutcome, held: DirectoryDurability) -> Step { match outcome { - RemovalOutcome::Removed(_) => Step::Unlinked, + RemovalOutcome::Removed(_) => Step::Unlinked(held), other => Step::Changed(other), } } @@ -397,9 +426,9 @@ fn settle( dir.sync()?; Ok(outcome) } - Step::Unlinked => { + Step::Unlinked(held) => { observer(RemovalStage::BeforeSync)?; - Ok(RemovalOutcome::Removed(dir.sync()?)) + Ok(RemovalOutcome::Removed(both_confirmed(dir.sync()?, held))) } } } diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index ed4f35701..51474d56e 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1760,9 +1760,13 @@ actor put at that name is left alone (what could be removed is at most an empty descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and -only reported as being there if the recorded name still leads to that directory and the entry is not known to be gone; if an -actor renamed the directory away or removed the entry the outcome is `Disturbed` (tampered, the entry cannot be found by name). -If the held directory was renamed away but the removal went on in it, that directory is synced too. The identity check before the +only reported as being there if the recorded name still leads to that directory and the entry in it is still the object the +outcome names (the authenticated object, or for a swapped-in object the identity it was seen with; an entry that cannot be looked +at counts as there, since an error proves neither); if an actor renamed the directory away, removed the entry or replaced it with +another object the outcome is `Disturbed` (tampered, the entry cannot be found where the outcome would say). The check is itself +a look at a name and does not close the same-user race. The quarantine directory the unlink happened in is synced as well as the +parent before `Removed` is reported, whether it is removed afterwards, survives because an actor put an entry into it, or was +renamed away; if that sync fails the removal is `Removed(NotConfirmed)`. The identity check before the name-based `rmdir` of the quarantine directory narrows the race and does not close it: a lost race removes at most an empty directory. A failure to move the entry at all (`Unsupported`, an error from `renameat2`) also syncs the parent, since the quarantine directory came and went. A umask that takes the owner's read access From aff16785dc6255314a5bb96a22d7ed7ef22ced8b Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:42:24 +0200 Subject: [PATCH 12/13] fix(core): sync the quarantine directory after a put-back and report a failed sync as itself (#445) The cross-directory rename that puts an entry back, or moves it aside, changes the quarantine directory as well as the parent. Only the parent was synced, so after a put-back or a kept entry a crash could undo the change in the quarantine directory while the parent already showed the name (CodeAnt risk note on e39e8377). The quarantine directory is now synced once after the rename was tried, in every path that moved an entry through it. The rename alone decides `restored` and the sync is a separate result, so a sync that fails after a rename that worked is not taken for a put-back that did not happen: no false `Disturbed` or `Stranded`, which a `sync_all()?` inside the rename step would have caused. - Swapped (put back, or kept aside): a failed sync is an error reported after the parent was synced, like a failing parent sync, and not another outcome. - RolledBack and Stranded: best effort, the reported cause is not replaced. - Disturbed (tampering) outranks a failed sync. - Removed: unchanged, `Removed(NotConfirmed)` when the sync fails. - Where nothing moved, nothing is synced. An fsync error on a directory cannot be provoked portably, so the failing cases use a small cfg(test) seam in `Quarantine::sync`; the cases without a failure run the real sync and count how often it was asked for. The seam also closes the gap that the Removed(NotConfirmed) path had no end-to-end test. Mutation-checked: leaving out the sync after a put-back, after a rollback, dropping a failed sync, a `sync_all()?` inside `restore()` and confirming a removal despite a failed sync each fail at least one of the new cases. test-support only, no caller, no new unsafe, no new package. Issue #359. --- CHANGELOG.md | 2 +- .../src/pinned/removal.rs | 8 +- .../src/pinned/tests/mod.rs | 2 + .../src/pinned/tests/remove_unix.rs | 22 +- .../src/pinned/tests/remove_unix_sync.rs | 208 ++++++++++++++++++ .../src/pinned/unix/removal.rs | 126 +++++++++-- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 25 ++- 7 files changed, 356 insertions(+), 37 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8026ba50..b060a8122 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:** 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; the quarantine directory is removed by its name only if the name still leads to the directory that was created; an entry is reported as retained only if the recorded name still leads to its directory, otherwise `Disturbed`; 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 #1072. +- **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 or a kept entry and makes a removal `NotConfirmed`, never another outcome); the quarantine directory is removed by its name only if the name still leads to the directory that was created; an entry is reported as retained only if the recorded name still leads to its directory, otherwise `Disturbed`; 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 #1072. - **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/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 7d2c59db3..9ffaf4b5e 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -62,7 +62,10 @@ pub enum RemovalOutcome { NotEmpty, /// Something other than the authenticated object was bound to the name. It was not removed: it is /// back under its name (`restored`) or, if the name was taken meanwhile, kept in the quarantine - /// directory (`.h1q-*`, never removed by a sweep). + /// directory (`.h1q-*`, never removed by a sweep). Reported only after the directory and the + /// quarantine directory the entry moved through were synced. A sync that fails is an error and not + /// another outcome: the rename decided `restored`, and the entry is back or aside whatever the sync + /// did. Swapped { restored: bool }, /// Unix only: the quarantine directory was tampered with. Either the authenticated object had not /// lost a name after the unlink, so the name was bound to another object when it was unlinked (see @@ -75,7 +78,8 @@ pub enum RemovalOutcome { /// Unix only: an error happened after the entry was moved into the quarantine directory and it /// could not be put back (`cause` is the error that started it). The entry is in the quarantine /// directory (`.h1q-*`, as `x`), found with a listing; [`PinnedDir::remove_leftover_file`] takes it - /// from there after authenticating it again. Nothing was removed. + /// from there after authenticating it again. Nothing was removed. This reports a failure by its + /// cause, so both directories are synced best effort and a crash may undo where the entry is. Stranded { cause: PinnedError }, } diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index 950f4ed6c..f9188d11a 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", unix))] +mod remove_unix_sync; #[cfg(windows)] mod windows_redirect; diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs index 7056e2e4a..addae62e5 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs @@ -18,15 +18,15 @@ use crate::pinned::unix::removal::{both_confirmed, make_usable, INSIDE}; use crate::pinned::{PinnedDir, PinnedError, RemovalOutcome, RemovalStage, QUARANTINE_PREFIX}; /// A pinned directory holding `a` = "alpha", with the paths a swap needs. -struct Fixture { +pub(super) struct Fixture { /// Keeps the directories until the test ends. _scratch: Scratch, - root: PathBuf, - aside: PathBuf, - pinned: PinnedDir, + pub(super) root: PathBuf, + pub(super) aside: PathBuf, + pub(super) pinned: PinnedDir, } -fn fixture(label: &str) -> Fixture { +pub(super) fn fixture(label: &str) -> Fixture { let scratch = Scratch::new(label); let root = scratch.root(); let aside = scratch.aside(); @@ -42,7 +42,7 @@ fn fixture(label: &str) -> Fixture { /// The stages of a removal, without their payload. #[derive(Clone, Copy, PartialEq)] -enum At { +pub(super) enum At { Checked, MovedAside, Identified, @@ -50,7 +50,7 @@ enum At { } /// The stage, and the path inside the quarantine directory once there is one. -fn at(stage: RemovalStage<'_>) -> (At, Option<&Path>) { +pub(super) fn at(stage: RemovalStage<'_>) -> (At, Option<&Path>) { match stage { RemovalStage::Checked => (At::Checked, None), RemovalStage::MovedAside { quarantined } => (At::MovedAside, Some(quarantined)), @@ -83,12 +83,12 @@ fn failing_at(here: At) -> impl FnMut(RemovalStage<'_>) -> Result<(), PinnedErro } } -fn denied() -> PinnedError { +pub(super) fn denied() -> PinnedError { PinnedError::Io(std::io::ErrorKind::PermissionDenied) } /// The last-component swap: the original is renamed out of the way and a live file takes its name. -fn swap_in_a_live_file(root: &Path, aside: &Path) { +pub(super) fn swap_in_a_live_file(root: &Path, aside: &Path) { fs::rename(root.join("a"), aside.join("original")).unwrap(); fs::write(root.join("a"), b"live").unwrap(); } @@ -99,7 +99,7 @@ fn ino(path: &Path) -> u64 { } /// The quarantine directories a removal left in `root`. -fn quarantines(root: &Path) -> Vec { +pub(super) fn quarantines(root: &Path) -> Vec { let mut found: Vec = fs::read_dir(root) .unwrap() .map(|entry| entry.unwrap().path()) @@ -115,7 +115,7 @@ fn quarantines(root: &Path) -> Vec { } /// The content of the entry left in the only quarantine directory of `root`. -fn held_in_quarantine(root: &Path) -> Option> { +pub(super) fn held_in_quarantine(root: &Path) -> Option> { let all = quarantines(root); assert!(all.len() <= 1, "{all:?}"); fs::read(all.first()?.join(INSIDE)).ok() diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs new file mode 100644 index 000000000..5151aa97a --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs @@ -0,0 +1,208 @@ +//! The quarantine directory is synced whenever an entry moved through it, and a sync that fails is +//! reported where the outcome promises something and nowhere else. An `fsync` error on a directory +//! cannot be provoked portably, so the cases with a failing sync fail it through the seam of the removal +//! ([`sync_fault`]): that models the failure. The cases without a failure run the real sync, and the +//! seam counts how often it was asked for. + +use std::cell::Cell; +use std::fs; +use std::io::ErrorKind; + +use super::remove::holds; +use super::remove_unix::{ + at, denied, fixture, held_in_quarantine, quarantines, swap_in_a_live_file, At, Fixture, +}; +use crate::durable::DirectoryDurability::{Confirmed, NotConfirmed}; +use crate::pinned::unix::removal::sync_fault; +use crate::pinned::{PinnedError, RemovalOutcome, RemovalStage}; + +/// What the actor and the observer do during one removal of `a` ("alpha"). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Scene { + /// Nothing: the file is authenticated and removed. + Removal, + /// A live file takes the name after the check: it is moved aside, found to be foreign and put back. + PutBack, + /// As `PutBack`, and the name is taken again while the live file is aside: it stays aside. + KeptAside, + /// As `KeptAside`, and the quarantine directory is renamed away: the entry is not where the outcome + /// would say. + Tampered, + /// The observer fails once the entry is identified: it is put back and the error returned. + RolledBack, + /// The observer fails while the entry is aside and the name is taken again: it cannot go back. + Stranded, + /// The name is gone after the check: nothing is moved. + Vanished, + /// The observer fails at the check, before anything is moved. + FailedEarly, +} + +fn sync_failure() -> PinnedError { + PinnedError::Io(ErrorKind::Other) +} + +/// The doing of `scene` at one stage of the removal. +fn act( + scene: Scene, + f: &Fixture, + parent_synced: &Cell, + stage: RemovalStage<'_>, +) -> Result<(), PinnedError> { + let (now, inside) = at(stage); + let name = f.root.join("a"); + match (scene, now) { + (_, At::BeforeSync) => parent_synced.set(true), + (Scene::PutBack | Scene::KeptAside | Scene::Tampered, At::Checked) => { + swap_in_a_live_file(&f.root, &f.aside); + } + (Scene::Vanished, At::Checked) => fs::remove_file(name).unwrap(), + (Scene::FailedEarly, At::Checked) | (Scene::RolledBack, At::Identified) => { + return Err(denied()); + } + (Scene::KeptAside, At::MovedAside) => fs::write(name, b"retaken").unwrap(), + (Scene::Tampered, At::MovedAside) => { + fs::rename(inside.unwrap().parent().unwrap(), f.aside.join("held")).unwrap(); + fs::write(name, b"retaken").unwrap(); + } + (Scene::Stranded, At::MovedAside) => { + fs::write(name, b"taken").unwrap(); + return Err(denied()); + } + _ => {} + } + Ok(()) +} + +/// What one removal did and left. +#[derive(Clone, Debug, PartialEq)] +struct Report { + outcome: Result, + /// How often the quarantine directory was synced. + syncs: usize, + /// Whether the removal got as far as syncing the parent. + parent_synced: bool, + /// What the name holds afterwards. + name: Option>, + /// The entry kept in the quarantine directory that is left, if there is one. + kept: Option>, + /// How many quarantine directories are left. + left: usize, +} + +fn run(scene: Scene, failure: Option) -> Report { + let f = fixture("sync"); + let parent_synced = Cell::new(false); + let (outcome, syncs) = sync_fault::during(failure, || { + f.pinned + .remove_authenticated_file_observed("a", holds(b"alpha"), &mut |stage| { + act(scene, &f, &parent_synced, stage) + }) + }); + Report { + outcome, + syncs, + parent_synced: parent_synced.get(), + name: fs::read(f.root.join("a")).ok(), + kept: held_in_quarantine(&f.root), + left: quarantines(&f.root).len(), + } +} + +type Expected = (Scene, Result, usize, bool); + +#[test] +fn every_move_through_the_quarantine_directory_ends_with_one_sync_of_it() { + // A removal, a put-back, a kept entry, a rollback and a stranded entry each changed the quarantine + // directory, and the sync of the parent does not cover that. Nothing moved, nothing to sync there. + let expected: [Expected; 8] = [ + ( + Scene::Removal, + Ok(RemovalOutcome::Removed(Confirmed)), + 1, + true, + ), + ( + Scene::PutBack, + Ok(RemovalOutcome::Swapped { restored: true }), + 1, + true, + ), + ( + Scene::KeptAside, + Ok(RemovalOutcome::Swapped { restored: false }), + 1, + true, + ), + (Scene::Tampered, Ok(RemovalOutcome::Disturbed), 1, true), + (Scene::RolledBack, Err(denied()), 1, true), + ( + Scene::Stranded, + Ok(RemovalOutcome::Stranded { cause: denied() }), + 1, + true, + ), + (Scene::Vanished, Ok(RemovalOutcome::Vanished), 0, true), + (Scene::FailedEarly, Err(denied()), 0, false), + ]; + let actual = expected.map(|(scene, ..)| { + let report = run(scene, None); + (scene, report.outcome, report.syncs, report.parent_synced) + }); + assert_eq!(actual, expected); +} + +#[test] +fn a_failed_sync_of_the_quarantine_directory_is_an_error_after_a_put_back_and_after_a_kept_entry() { + // The rename decided where the entry is, whatever the sync did: it is back, or aside, and the parent + // was synced. Only the result differs, and it is the sync error, not `Disturbed` or `Stranded`. + for scene in [Scene::PutBack, Scene::KeptAside] { + let clean = run(scene, None); + let failed = run(scene, Some(sync_failure())); + let (name, kept, left) = match scene { + Scene::PutBack => (b"live".to_vec(), None, 0), + _ => (b"retaken".to_vec(), Some(b"live".to_vec()), 1), + }; + assert_eq!( + (failed.name.clone(), failed.kept.clone(), failed.left), + (Some(name), kept, left), + "{scene:?}" + ); + assert_eq!( + failed, + Report { + outcome: Err(sync_failure()), + ..clean + }, + "{scene:?}" + ); + } +} + +#[test] +fn a_failed_sync_changes_no_outcome_that_reports_a_failure_or_tampering() { + // `RolledBack` and `Stranded` report a failure by its cause (the sync is best effort and does not + // replace it); `Disturbed` is tampering and wins; and where nothing moved there is nothing to sync. + for scene in [ + Scene::RolledBack, + Scene::Stranded, + Scene::Tampered, + Scene::Vanished, + Scene::FailedEarly, + ] { + assert_eq!( + run(scene, Some(sync_failure())), + run(scene, None), + "{scene:?}" + ); + } +} + +#[test] +fn a_removal_whose_quarantine_directory_could_not_be_synced_is_not_confirmed() { + let failed = run(Scene::Removal, Some(sync_failure())); + assert_eq!( + (failed.outcome, failed.syncs, failed.name, failed.left), + (Ok(RemovalOutcome::Removed(NotConfirmed)), 1, None, 0) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index bd9584e26..3d01a2e59 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -48,9 +48,14 @@ enum Inside { /// How a removal ended before the parent directory was synced. enum Step { - /// The directory changed (an entry was moved and put back, or a quarantine directory came and - /// went); the outcome is final. + /// The directory changed (a quarantine directory came and went, or the entry was found gone); the + /// outcome is final. Changed(RemovalOutcome), + /// The entry was moved aside and then put back or kept aside, so the quarantine directory changed as + /// well as the parent; the outcome is final, and `held` is how syncing the quarantine directory went. + /// A sync that failed does not change what the outcome says about where the entry is, and is reported + /// after the parent was synced. + Moved(RemovalOutcome, Result<(), PinnedError>), /// The object lost its name; whether the quarantine directory it was unlinked from was synced. Unlinked(DirectoryDurability), /// The entry was moved aside and put back; this is why the removal stopped. @@ -165,11 +170,23 @@ impl Quarantine { } /// Moves the entry back under `name`; an error if that was not possible (the name is taken, or - /// anything else prevents it), in which case the entry stays here. + /// anything else prevents it), in which case the entry stays here. Only the rename: the sync of this + /// directory is [`sync`](Self::sync), kept apart so that a sync that fails cannot be taken for a + /// put-back that did not happen. fn restore(&self, parent: &DirHandle, name: &OsStr) -> Result<(), PinnedError> { renameat_with(&self.fd, INSIDE, parent, name, RenameFlags::NOREPLACE).map_err(io_error) } + /// Syncs this directory. Every move into or out of it changes it, and the sync of the parent does + /// not cover that: without it a crash can undo the removal of `x` after the parent shows the name + /// again (or leave `x` missing after the parent shows the name gone). + fn sync(&self) -> Result<(), PinnedError> { + if let Some(error) = forced_sync_failure() { + return Err(error); + } + self.fd.sync_all().map_err(PinnedError::from) + } + fn path_inside(&self, parent_path: &Path) -> PathBuf { parent_path.join(&self.name).join(INSIDE) } @@ -322,11 +339,18 @@ impl Removal<'_> { Ok(true) => {} } match self.judge(observer) { - Err(cause) => match self.restore() { - Ok(()) => Ok(Step::RolledBack(cause)), - Err(_) if self.retained(Some(self.target.expected)) => Ok(Step::Stranded(cause)), - Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), - }, + Err(cause) => { + let put_back = self.restore(); + // The failure that is reported is `cause`, and a sync that fails does not replace it. + let _ = self.quarantine.sync(); + match put_back { + Ok(()) => Ok(Step::RolledBack(cause)), + Err(_) if self.retained(Some(self.target.expected)) => { + Ok(Step::Stranded(cause)) + } + Err(_) => Ok(Step::Changed(RemovalOutcome::Disturbed)), + } + } step => step, } } @@ -348,12 +372,16 @@ impl Removal<'_> { /// What arrived is not the authenticated object: it goes back under its name, or stays aside if /// that is impossible (and is then still `arrived`, the identity it was seen with if it was opened). + /// Either way the quarantine directory changed (the entry left it, or arrived in it), and it is synced + /// after the rename was tried: the rename alone decides `restored`, so a sync that fails is neither a + /// put-back that did not happen nor a reason to call the entry tampered with. fn swapped(&self, arrived: Option) -> Result { let restored = self.restore().is_ok(); + let held = self.quarantine.sync(); if !restored && !self.retained(arrived) { return Ok(Step::Changed(RemovalOutcome::Disturbed)); } - Ok(Step::Changed(RemovalOutcome::Swapped { restored })) + Ok(Step::Moved(RemovalOutcome::Swapped { restored }, held)) } /// The last window: the entry in the quarantine directory is the authenticated object. @@ -366,14 +394,17 @@ impl Removal<'_> { // A directory that filled after the check goes back where it was. RemovalOutcome::NotEmpty => { self.restore()?; - Ok(Step::Changed(RemovalOutcome::NotEmpty)) + Ok(Step::Moved( + RemovalOutcome::NotEmpty, + self.quarantine.sync(), + )) } outcome => { // The unlink changed the directory that is held, and that directory can outlive the // removal (an actor renamed it away, or put an entry into it so that it cannot be // removed). The parent sync does not cover it, so it is synced here, and a sync that // fails leaves the removal unconfirmed. - let held = match self.quarantine.fd.sync_all() { + let held = match self.quarantine.sync() { Ok(()) => DirectoryDurability::Confirmed, Err(_) => DirectoryDurability::NotConfirmed, }; @@ -386,13 +417,12 @@ impl Removal<'_> { self.quarantine.restore(&self.dir.handle, self.target.name) } - /// The entry stays in the quarantine directory: that directory changed as well as the parent, so - /// it is synced too (best effort), and the outcome may only say where the entry is if the recorded - /// name still leads to the directory that holds it and the entry in it is still `expected` (or - /// cannot be looked at). If an actor renamed that directory away, or removed or replaced the entry, - /// the entry cannot be found where the outcome would say, and that is reported as tampering. + /// The entry stays in the quarantine directory: the outcome may only say where the entry is if the + /// recorded name still leads to the directory that holds it and the entry in it is still `expected` + /// (or cannot be looked at). If an actor renamed that directory away, or removed or replaced the + /// entry, the entry cannot be found where the outcome would say, and that is reported as tampering. + /// Syncing that directory, which changed as well as the parent, is up to the caller. fn retained(&self, expected: Option) -> bool { - let _ = self.quarantine.fd.sync_all(); self.quarantine.is_named(&self.dir.handle) && self.quarantine.holds(expected) } } @@ -406,7 +436,9 @@ fn unlinked(outcome: RemovalOutcome, held: DirectoryDurability) -> Step { /// Syncs the parent directory if anything in it changed. A sync that fails is an error although the /// entry is gone or back: a crash may undo that, and the caller repeats [`PinnedDir::sync`] (a rerun of -/// the removal finds nothing to do and would not sync). +/// the removal finds nothing to do and would not sync). The same holds for the quarantine directory of an +/// entry that was put back or kept aside; it is reported after the parent was synced, whether or not the +/// directory is removed afterwards, since a sync that failed proves nothing about what reached the disk. fn settle( dir: &PinnedDir, step: Step, @@ -426,6 +458,11 @@ fn settle( dir.sync()?; Ok(outcome) } + Step::Moved(outcome, held) => { + observer(RemovalStage::BeforeSync)?; + dir.sync()?; + held.map(|()| outcome) + } Step::Unlinked(held) => { observer(RemovalStage::BeforeSync)?; Ok(RemovalOutcome::Removed(both_confirmed(dir.sync()?, held))) @@ -458,3 +495,56 @@ fn sync_after_failure(dir: &PinnedDir, observer: Observer<'_>) { let _ = observer(RemovalStage::BeforeSync); let _ = dir.sync(); } + +/// The failure a test forced on the sync of a quarantine directory, if any. An `fsync` error on a +/// directory cannot be provoked portably, so the sync asks here first; a build without tests never does. +#[cfg(test)] +fn forced_sync_failure() -> Option { + sync_fault::asked() +} + +#[cfg(not(test))] +fn forced_sync_failure() -> Option { + None +} + +/// The seam behind [`forced_sync_failure`]: a test runs a removal with the sync of the quarantine +/// directory failing (or not), and learns how often that sync was asked for. +#[cfg(test)] +pub(in crate::pinned) mod sync_fault { + use std::cell::Cell; + + use crate::pinned::PinnedError; + + thread_local! { + static FAILURE: Cell> = const { Cell::new(None) }; + static ASKED: Cell = const { Cell::new(0) }; + } + + /// Disarms the seam when the removal under test ends, also by a panic. + struct Disarm; + + impl Drop for Disarm { + fn drop(&mut self) { + FAILURE.set(None); + } + } + + /// Runs `body` with every sync of a quarantine directory failing with `failure` (none: succeeding), + /// and returns what it returned with the number of syncs that were asked for. + pub(in crate::pinned) fn during( + failure: Option, + body: impl FnOnce() -> T, + ) -> (T, usize) { + FAILURE.set(failure); + ASKED.set(0); + let _disarm = Disarm; + let result = body(); + (result, ASKED.get()) + } + + pub(super) fn asked() -> Option { + ASKED.set(ASKED.get() + 1); + FAILURE.get() + } +} diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index 51474d56e..d4914aeea 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1754,13 +1754,21 @@ returned, or, if it cannot be put back, the outcome is `Stranded { cause }` (the nothing was removed) and not an error that hides where the entry is. The parent is synced whenever the directory changed: after a removal and after a put-back as a step of the removal (a sync that fails is an error although the entry is gone or back, and the caller repeats `PinnedDir::sync`; a rerun of the removal finds nothing to do and would not sync), and after a rolled-back or -stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). The +stranded failure best-effort (the `BeforeSync` stage is reached; a failing sync does not replace the failure that is reported). **The +quarantine directory changes with every move, and the sync of the parent does not cover it:** the rename that moves an entry in +and the rename that puts it back each change it as well as the parent, so it is synced after the rename was tried. For a put-back +and a kept entry (`Swapped`) a sync that fails is an error reported after the parent was synced, like a failing parent sync, and +not another outcome: the rename alone decides `restored`, so the entry is back or aside whatever the sync did, and the failure is +neither a put-back that did not happen (a `sync_all()?` inside the rename step would make it one, and report `Disturbed`) nor +`Stranded`. It is reported whether or not the directory is removed afterwards, since a failed sync proves nothing about what +reached the disk. For a rollback and a stranded entry the sync is best effort and does not replace the failure that is reported, +and tampering (`Disturbed`) outranks a failed sync. Where nothing moved, nothing is synced in it. The quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine -directory (`restored: false`, `Stranded`) is retained with a best-effort sync of that directory as well as of the parent, and -only reported as being there if the recorded name still leads to that directory and the entry in it is still the object the +directory (`restored: false`, `Stranded`) is only reported as being there if the recorded name still leads to that directory and +the entry in it is still the object the outcome names (the authenticated object, or for a swapped-in object the identity it was seen with; an entry that cannot be looked at counts as there, since an error proves neither); if an actor renamed the directory away, removed the entry or replaced it with another object the outcome is `Disturbed` (tampered, the entry cannot be found where the outcome would say). The check is itself @@ -1789,12 +1797,19 @@ directory before the identification; the last window forced with a foreign file directory (goes back) and a hard link (object alive under the other name); a name taken after the move, a vanished name, renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported -for a removal and for a put-back and repeated by `sync`; a quarantine directory that loses its search permission after the +for a removal and for a put-back and repeated by `sync`; the quarantine directory is synced once after a removal, a put-back, a +kept entry, a rollback and a stranded entry and not when nothing moved, and a sync of it that fails is an error for the put-back and +the kept entry (the entry where the rename left it), leaves a rollback, a stranded entry, tampering and the outcomes before the move +as they are, and makes a removal `NotConfirmed` (an `fsync` error on a directory cannot be provoked portably, so these cases fail +the sync through a test-only seam, which models the failure; the cases without a failure run the real sync); a quarantine +directory that loses its search permission after the move gives `Stranded` with the entry still the authenticated inode, and the same case without permissions (an observer that fails while the name is taken again, so it runs the same for root); a quarantine directory renamed away and replaced by an empty directory of the actor is not removed. Mutation-checked: skipping the identity comparison fails five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a -failed inspection fails the `Stranded` case. +failed inspection fails the `Stranded` case; leaving out the sync of the quarantine directory after a put-back or after a rollback, +dropping a failed sync of it, a `sync_all()?` inside the rename step and confirming a removal despite a failed sync each fail at +least one of the sync cases. **Not claimed.** Directories and the leftover recovery are part 3 (they answer `Unsupported` here); Windows is part 4; the Disturbed window is bounded and detected, not closed; a device node swapped in is refused before it is opened and cannot be From ae96312f192a4c2cdc54fd465027660462e8c0a7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:17:29 +0200 Subject: [PATCH 13/13] fix(core): sync the quarantine directory for an entry found gone, check the identity when a failed setup is cleaned up (#445) Two findings on aff16785, both valid. An entry that was moved aside and then found gone ("Vanished" at the inspection, or an ENOENT at the unlink) had changed the quarantine directory too, and the previous commit did not sync it for that outcome (Codex P2 and CodeRabbit, independently): the earlier claim "every path that moved an entry through it" was too broad. Both paths now go through Step::Moved like a put-back: the directory is synced after the parent, and a sync that fails is the error instead of a completed removal. The unlink path used to run the sync and drop its result for this outcome. Quarantine::create removed a directory by its name after a failed setup without the identity check that discard() has (CodeAnt). The identity is now taken right after mkdirat, which returns no descriptor, and the cleanup removes the name only if it still leads to that directory; without an identity nothing is removed. discard() and the cleanup share remove_if_named(). rmdir takes a name, not a descriptor, so this narrows the race and does not close it: what a lost race can remove is at most an empty directory. Tests: two scenes (entry gone before the inspection, gone before the unlink) in the sync cases, with and without a failing sync; the removal by name is exercised on its own (the directory that was made goes; one renamed away and replaced, a non-empty one and a name without an identity stay). The setup failure that reaches the cleanup in create() needs a changed process umask and is not forced. Mutation-checked: skipping the sync for an entry gone at the inspection, dropping it for one gone at the unlink, and removing the identity comparison each fail at least one case. CHANGELOG and the evidence document say what the tests pin. test-support only, no caller, no new unsafe, no new package. Issue #359. --- CHANGELOG.md | 2 +- .../src/pinned/removal.rs | 4 +- .../src/pinned/tests/mod.rs | 2 + .../src/pinned/tests/remove_unix_cleanup.rs | 65 ++++++++++++++++ .../src/pinned/tests/remove_unix_sync.rs | 49 +++++++++--- .../src/pinned/unix/removal.rs | 78 ++++++++++++------- .../r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md | 22 ++++-- 7 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 crates/worldscript-secure-storage/src/pinned/tests/remove_unix_cleanup.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b060a8122..850313193 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:** 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 or a kept entry and makes a removal `NotConfirmed`, never another outcome); the quarantine directory is removed by its name only if the name still leads to the directory that was created; an entry is reported as retained only if the recorded name still leads to its directory, otherwise `Disturbed`; 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 #1072. +- **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 if the name still leads to the directory that was created; an entry is reported as retained only if the recorded name still leads to its directory, otherwise `Disturbed`; 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 #1072. - **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/removal.rs b/crates/worldscript-secure-storage/src/pinned/removal.rs index 9ffaf4b5e..b5d9f9273 100644 --- a/crates/worldscript-secure-storage/src/pinned/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/removal.rs @@ -56,7 +56,9 @@ pub enum RemovalOutcome { Removed(DirectoryDurability), /// The authentication said no; nothing was changed. Refused, - /// The name no longer led to an object (someone else removed it); nothing was removed. + /// The name no longer led to an object (someone else removed it); nothing was removed. If the entry + /// had been moved aside and was gone from there, the quarantine directory was synced as for + /// [`Swapped`](Self::Swapped), and a sync that fails is an error. Vanished, /// The directory still has entries; nothing was removed. NotEmpty, diff --git a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs index f9188d11a..7479f89d5 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/mod.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/mod.rs @@ -11,6 +11,8 @@ mod remove; #[cfg(all(feature = "test-support", unix))] mod remove_unix; #[cfg(all(feature = "test-support", unix))] +mod remove_unix_cleanup; +#[cfg(all(feature = "test-support", unix))] mod remove_unix_sync; #[cfg(windows)] mod windows_redirect; diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_cleanup.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_cleanup.rs new file mode 100644 index 000000000..fa4825678 --- /dev/null +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_cleanup.rs @@ -0,0 +1,65 @@ +//! The cleanup of a quarantine directory by its name removes the directory that was made, and only when +//! it is empty. A name that leads somewhere else by then (an actor renamed the directory away and put +//! one of its own at the name) is left alone, and so is everything when no identity was taken. The check +//! narrows the race and does not close it (`rmdir` takes a name); these cases pin what it does decide. + +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +use super::remove_unix::fixture; +use crate::pinned::unix::removal::remove_if_named; +use crate::pinned::ObjectIdentity; + +fn identity(path: &Path) -> ObjectIdentity { + let metadata = fs::symlink_metadata(path).unwrap(); + ObjectIdentity::new(metadata.dev(), u128::from(metadata.ino())) +} + +/// What is left after `remove_if_named` was asked about the directory `q` of a fixture whose `setup` +/// ran: whether it reported a removal, whether the name still leads to a directory, and whether the +/// directory that was made is still there (renamed away). +fn cleanup( + label: &str, + setup: impl FnOnce(&Path, &Path), + expected: impl FnOnce(&Path) -> Option, +) -> (bool, bool, bool) { + let f = fixture(label); + let q = f.root.join("q"); + fs::create_dir(&q).unwrap(); + let wanted = expected(&q); + setup(&q, &f.aside); + let removed = remove_if_named(&f.pinned.handle, "q".as_ref(), wanted); + (removed, q.is_dir(), f.aside.join("made").is_dir()) +} + +#[test] +fn only_the_directory_that_was_made_is_removed_and_only_when_empty() { + let same = cleanup("same", |_, _| {}, |q| Some(identity(q))); + // The directory was renamed away and an empty one of the actor's took its name. + let replaced = cleanup( + "replaced", + |q, aside| { + fs::rename(q, aside.join("made")).unwrap(); + fs::create_dir(q).unwrap(); + }, + |q| Some(identity(q)), + ); + // The directory is not empty: `rmdir` refuses, and nothing of it is touched. + let occupied = cleanup( + "occupied", + |q, _| fs::write(q.join("entry"), b"x").unwrap(), + |q| Some(identity(q)), + ); + // No identity was taken: nothing is removed, however plausible the name looks. + let unknown = cleanup("unknown", |_, _| {}, |_| None); + assert_eq!( + (same, replaced, occupied, unknown), + ( + (true, false, false), + (false, true, true), + (false, true, false), + (false, true, false) + ) + ); +} diff --git a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs index 5151aa97a..8db4e5f19 100644 --- a/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs +++ b/crates/worldscript-secure-storage/src/pinned/tests/remove_unix_sync.rs @@ -34,6 +34,10 @@ enum Scene { Stranded, /// The name is gone after the check: nothing is moved. Vanished, + /// An actor removes the entry once it is aside, before it is looked at. + GoneBeforeInspection, + /// An actor removes the entry once it is identified, before it is unlinked. + GoneBeforeUnlink, /// The observer fails at the check, before anything is moved. FailedEarly, } @@ -57,6 +61,10 @@ fn act( swap_in_a_live_file(&f.root, &f.aside); } (Scene::Vanished, At::Checked) => fs::remove_file(name).unwrap(), + (Scene::GoneBeforeInspection, At::MovedAside) + | (Scene::GoneBeforeUnlink, At::Identified) => { + fs::remove_file(inside.unwrap()).unwrap(); + } (Scene::FailedEarly, At::Checked) | (Scene::RolledBack, At::Identified) => { return Err(denied()); } @@ -113,9 +121,10 @@ type Expected = (Scene, Result, usize, bool); #[test] fn every_move_through_the_quarantine_directory_ends_with_one_sync_of_it() { - // A removal, a put-back, a kept entry, a rollback and a stranded entry each changed the quarantine - // directory, and the sync of the parent does not cover that. Nothing moved, nothing to sync there. - let expected: [Expected; 8] = [ + // A removal, a put-back, a kept entry, a rollback, a stranded entry and an entry an actor removed + // again each changed the quarantine directory, and the sync of the parent does not cover that. + // Nothing moved, nothing to sync there. + let expected: [Expected; 10] = [ ( Scene::Removal, Ok(RemovalOutcome::Removed(Confirmed)), @@ -143,6 +152,19 @@ fn every_move_through_the_quarantine_directory_ends_with_one_sync_of_it() { true, ), (Scene::Vanished, Ok(RemovalOutcome::Vanished), 0, true), + // The entry was moved here, so this directory changed, although an actor removed the entry again. + ( + Scene::GoneBeforeInspection, + Ok(RemovalOutcome::Vanished), + 1, + true, + ), + ( + Scene::GoneBeforeUnlink, + Ok(RemovalOutcome::Vanished), + 1, + true, + ), (Scene::FailedEarly, Err(denied()), 0, false), ]; let actual = expected.map(|(scene, ..)| { @@ -153,19 +175,26 @@ fn every_move_through_the_quarantine_directory_ends_with_one_sync_of_it() { } #[test] -fn a_failed_sync_of_the_quarantine_directory_is_an_error_after_a_put_back_and_after_a_kept_entry() { - // The rename decided where the entry is, whatever the sync did: it is back, or aside, and the parent - // was synced. Only the result differs, and it is the sync error, not `Disturbed` or `Stranded`. - for scene in [Scene::PutBack, Scene::KeptAside] { +fn a_failed_sync_of_the_quarantine_directory_is_an_error_after_a_put_back_a_kept_entry_and_a_vanished_one( +) { + // The rename decided where the entry is, whatever the sync did: it is back, aside or gone, and the + // parent was synced. Only the result differs, and it is the sync error, not `Disturbed` or `Stranded`. + for scene in [ + Scene::PutBack, + Scene::KeptAside, + Scene::GoneBeforeInspection, + Scene::GoneBeforeUnlink, + ] { let clean = run(scene, None); let failed = run(scene, Some(sync_failure())); let (name, kept, left) = match scene { - Scene::PutBack => (b"live".to_vec(), None, 0), - _ => (b"retaken".to_vec(), Some(b"live".to_vec()), 1), + Scene::PutBack => (Some(b"live".to_vec()), None, 0), + Scene::KeptAside => (Some(b"retaken".to_vec()), Some(b"live".to_vec()), 1), + _ => (None, None, 0), }; assert_eq!( (failed.name.clone(), failed.kept.clone(), failed.left), - (Some(name), kept, left), + (name, kept, left), "{scene:?}" ); assert_eq!( diff --git a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs index 3d01a2e59..d1b59b705 100644 --- a/crates/worldscript-secure-storage/src/pinned/unix/removal.rs +++ b/crates/worldscript-secure-storage/src/pinned/unix/removal.rs @@ -48,13 +48,13 @@ enum Inside { /// How a removal ended before the parent directory was synced. enum Step { - /// The directory changed (a quarantine directory came and went, or the entry was found gone); the - /// outcome is final. + /// The directory changed (a quarantine directory came and went, the name led nowhere, or tampering + /// was found); the outcome is final. Changed(RemovalOutcome), - /// The entry was moved aside and then put back or kept aside, so the quarantine directory changed as - /// well as the parent; the outcome is final, and `held` is how syncing the quarantine directory went. - /// A sync that failed does not change what the outcome says about where the entry is, and is reported - /// after the parent was synced. + /// The entry was moved aside and then put back, kept aside or found gone, so the quarantine directory + /// changed as well as the parent; the outcome is final, and `held` is how syncing the quarantine + /// directory went. A sync that failed does not change what the outcome says about where the entry is, + /// and is reported after the parent was synced. Moved(RemovalOutcome, Result<(), PinnedError>), /// The object lost its name; whether the quarantine directory it was unlinked from was synced. Unlinked(DirectoryDurability), @@ -73,10 +73,13 @@ impl Quarantine { let suffix: String = bits.iter().map(|byte| format!("{byte:02x}")).collect(); let name = OsString::from(format!("{QUARANTINE_PREFIX}{suffix}")); mkdirat(parent, &name, Mode::RWXU).map_err(io_error)?; + // `mkdirat` returns no descriptor, so what the name leads to right now stands for the directory + // that was made; a setup that fails removes the name again only if it still leads to that. + let made = identity_at(parent, &name); match Self::made_usable(parent, &name).and_then(|fd| Self::held(fd, name.clone())) { Ok(held) => Ok(held), Err(error) => { - if unlinkat(parent, &name, AtFlags::REMOVEDIR).is_ok() { + if remove_if_named(parent, &name, made) { // The directory came and went: sync the parent best effort, so the error does not // leave an unsynced quarantine directory behind. let _ = parent.sync_all(); @@ -192,13 +195,10 @@ impl Quarantine { } /// Removes this directory if it is empty. A directory that still holds an entry is left, and so is - /// whatever the name leads to if that is no longer the directory that was created. The check narrows - /// the race and does not close it (`rmdir` takes a name, not a descriptor): what a lost race can - /// remove is at most an empty directory. + /// whatever the name leads to if that is no longer the directory that was created (see + /// [`remove_if_named`]). fn discard(&self, parent: &DirHandle) { - if self.is_named(parent) { - let _ = unlinkat(parent, &self.name, AtFlags::REMOVEDIR); - } + remove_if_named(parent, &self.name, Some(self.identity)); } /// Whether the entry may still be inside: `false` only if it is known to be gone, or to be another @@ -217,14 +217,32 @@ impl Quarantine { /// Whether the name still leads to the directory that was created. fn is_named(&self, parent: &DirHandle) -> bool { - stat_child(parent, &self.name) - .ok() - .flatten() - .and_then(|stat| identity_from(&stat).ok()) - .is_some_and(|found| found == self.identity) + identity_at(parent, &self.name) == Some(self.identity) } } +/// The identity of what `name` of `parent` leads to right now, without following a link; `None` if it +/// leads nowhere or cannot be looked at. +fn identity_at(parent: &DirHandle, name: &OsStr) -> Option { + stat_child(parent, name) + .ok() + .flatten() + .and_then(|stat| identity_from(&stat).ok()) +} + +/// Removes the directory `name` of `parent` if it is empty and still leads to `expected`; whether it was +/// removed. Without an identity nothing is removed. The check narrows the race and does not close it +/// (`rmdir` takes a name, not a descriptor): what a lost race can remove is at most an empty directory. +pub(in crate::pinned) fn remove_if_named( + parent: &DirHandle, + name: &OsStr, + expected: Option, +) -> bool { + expected.is_some() + && identity_at(parent, name) == expected + && unlinkat(parent, name, AtFlags::REMOVEDIR).is_ok() +} + /// Makes the quarantine directory `name` of `parent` usable the way [`Quarantine::create`] does: the /// owner's access is set explicitly, whatever the umask did to the mode it was created with. #[cfg(test)] @@ -361,7 +379,12 @@ impl Removal<'_> { quarantined: &inside, })?; match self.quarantine.inspect(self.target.kind)? { - Inside::Absent => Ok(Step::Changed(RemovalOutcome::Vanished)), + // An actor removed the entry after it was moved here: that moved it into this directory, and + // out again, without the sync of the parent covering either. + Inside::Absent => Ok(Step::Moved( + RemovalOutcome::Vanished, + self.quarantine.sync(), + )), Inside::Object(object, found) if found == self.target.expected => { self.conclude(&object, observer) } @@ -404,11 +427,7 @@ impl Removal<'_> { // removal (an actor renamed it away, or put an entry into it so that it cannot be // removed). The parent sync does not cover it, so it is synced here, and a sync that // fails leaves the removal unconfirmed. - let held = match self.quarantine.sync() { - Ok(()) => DirectoryDurability::Confirmed, - Err(_) => DirectoryDurability::NotConfirmed, - }; - Ok(unlinked(outcome, held)) + Ok(unlinked(outcome, self.quarantine.sync())) } } } @@ -427,9 +446,16 @@ impl Removal<'_> { } } -fn unlinked(outcome: RemovalOutcome, held: DirectoryDurability) -> Step { +/// What the unlink came to, with how syncing the quarantine directory it was made in went: a removal is +/// confirmed only if that sync worked, and an entry that was gone by the time of the unlink is reported +/// like one that was gone before it. Tampering (`Disturbed`) outranks a failed sync. +fn unlinked(outcome: RemovalOutcome, held: Result<(), PinnedError>) -> Step { match outcome { - RemovalOutcome::Removed(_) => Step::Unlinked(held), + RemovalOutcome::Removed(_) => Step::Unlinked(match held { + Ok(()) => DirectoryDurability::Confirmed, + Err(_) => DirectoryDurability::NotConfirmed, + }), + RemovalOutcome::Vanished => Step::Moved(outcome, held), other => Step::Changed(other), } } diff --git a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md index d4914aeea..a7cb926d1 100644 --- a/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md +++ b/docs/native/r15/GATE4D-JOURNAL-DURABLE-EVIDENCE.md @@ -1762,9 +1762,13 @@ not another outcome: the rename alone decides `restored`, so the entry is back o neither a put-back that did not happen (a `sync_all()?` inside the rename step would make it one, and report `Disturbed`) nor `Stranded`. It is reported whether or not the directory is removed afterwards, since a failed sync proves nothing about what reached the disk. For a rollback and a stranded entry the sync is best effort and does not replace the failure that is reported, -and tampering (`Disturbed`) outranks a failed sync. Where nothing moved, nothing is synced in it. The +and tampering (`Disturbed`) outranks a failed sync. An entry that an actor removed again after it was moved aside (found gone at the +inspection, or at the unlink) is reported as `Vanished` and treated like a put-back: the move changed the directory, so it is +synced, and a sync that fails is the error. Only where the name led nowhere, so that nothing moved, nothing is synced in it. The quarantine directory is removed by its name only if the name still leads to the directory that was created, so a directory an -actor put at that name is left alone (what could be removed is at most an empty directory). That the directory behind the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no +actor put at that name is left alone (what could be removed is at most an empty directory); this holds for the cleanup of a setup +that fails as well, where the identity is taken right after `mkdirat` and nothing is removed without one. That the directory behind +the name is the one `mkdirat` made cannot be proved (`mkdirat` returns no descriptor); it is opened by name and its owner and mode are checked, and since the quarantine directory is not exclusive anyway, an actor that wins that earlier race gains nothing the window below does not already give it. An entry that stays in the quarantine directory (`restored: false`, `Stranded`) is only reported as being there if the recorded name still leads to that directory and @@ -1798,18 +1802,22 @@ directory (goes back) and a hard link (object alive under the other name); a nam renaming the root during the removal; the quarantine directory is mode 0700 and gone afterwards, and a directory made with mode 0500 is made usable; an injected failure at each stage leaves the directory as it was found; a failed sync is reported for a removal and for a put-back and repeated by `sync`; the quarantine directory is synced once after a removal, a put-back, a -kept entry, a rollback and a stranded entry and not when nothing moved, and a sync of it that fails is an error for the put-back and -the kept entry (the entry where the rename left it), leaves a rollback, a stranded entry, tampering and the outcomes before the move +kept entry, a rollback, a stranded entry and an entry an actor removed again, and not when nothing moved, and a sync of it that +fails is an error for the put-back, the kept entry and the entry that was gone (each where the move left it), leaves a rollback, a +stranded entry, tampering and the outcomes before the move as they are, and makes a removal `NotConfirmed` (an `fsync` error on a directory cannot be provoked portably, so these cases fail the sync through a test-only seam, which models the failure; the cases without a failure run the real sync); a quarantine directory that loses its search permission after the move gives `Stranded` with the entry still the authenticated inode, and the same case without permissions (an observer that fails while the name is taken again, so it runs the same for root); a quarantine directory renamed away and replaced by an empty -directory of the actor is not removed. Mutation-checked: skipping the identity comparison fails +directory of the actor is not removed, and the removal of a directory by its name is exercised on its own (the directory that was +made goes, one renamed away and replaced, a non-empty one and a name without an identity stay; the setup failure that reaches +it in `create` cannot be provoked without changing the process umask and is not forced). Mutation-checked: skipping the identity comparison fails five of these cases, skipping the link-count verification fails the forced last window, and removing the rollback of a failed inspection fails the `Stranded` case; leaving out the sync of the quarantine directory after a put-back or after a rollback, -dropping a failed sync of it, a `sync_all()?` inside the rename step and confirming a removal despite a failed sync each fail at -least one of the sync cases. +dropping a failed sync of it, a `sync_all()?` inside the rename step, confirming a removal despite a failed sync, skipping the +sync for an entry found gone at the inspection and dropping it for one gone at the unlink each fail at least one of the sync +cases. **Not claimed.** Directories and the leftover recovery are part 3 (they answer `Unsupported` here); Windows is part 4; the Disturbed window is bounded and detected, not closed; a device node swapped in is refused before it is opened and cannot be