Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions crates/worldscript-secure-storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/worldscript-secure-storage/src/pinned/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
148 changes: 145 additions & 3 deletions crates/worldscript-secure-storage/src/pinned/tests/remove.rs
Original file line number Diff line number Diff line change
@@ -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<bool, PinnedError> {
move |file| Ok(file.read_at_most(64)? == expected)
}

/// What a completed removal reports on this platform.
#[cfg(unix)]
pub(super) fn removed() -> Result<RemovalOutcome, PinnedError> {
let durability = if cfg!(unix) {
DirectoryDurability::Confirmed
} else {
DirectoryDurability::NotConfirmed
};
Ok(RemovalOutcome::Removed(durability))
}

#[cfg(unix)]
pub(super) fn names(pinned: &PinnedDir) -> Vec<String> {
let mut listed: Vec<String> = 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();
Expand Down
Loading
Loading