Skip to content
Closed
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 directory on Unix, the recovery of leftovers and the crash cases, `test-support` only (slice H1a-2, part 3 of 4). `remove_empty_dir` moves the directory through the same quarantine directory and checks the link count of the held directory afterwards; `remove_leftover_file` takes a leftover `.h1q-*` directory by its generated name only, authenticates the file in it again and removes it only if accepted. A simulated crash, an entry stranded by a lost permission and a foreign object left by a refused put-back are covered. No new `unsafe`, no new package. Issue #359. PR #1070.

- **R-15 Gate 4:** removal of a file on Unix, `test-support` only (slice H1a-2, part 2 of 4). `PinnedDir::remove_authenticated_file` moves the entry into a quarantine directory with `RENAME_NOREPLACE`, identifies the open object there and only then unlinks it; anything else is put back, a failure after the move rolls back or reports `Stranded`, and the parent is synced. The one window no Unix primitive closes (a same-user actor in the quarantine directory after the last identification) is forced in a test and reported as `Disturbed`, detected after the fact and not prevented. The rustix `process` feature is added for `geteuid`; no new package, no new `unsafe`. Directories, leftovers and Windows follow. Issue #359. PR #1069.

- **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.
Expand Down
34 changes: 34 additions & 0 deletions crates/worldscript-secure-storage/src/pinned/tests/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,40 @@ fn removing_a_hard_linked_name_leaves_the_other_names() {
);
}

#[cfg(unix)]
#[test]
fn an_empty_directory_is_removed_and_a_filled_one_or_a_link_is_not() {
let scratch = Scratch::new("dirs");
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()]
)
);
}

#[cfg(windows)]
#[test]
fn every_removal_refuses_until_windows_implements_it_and_removes_nothing() {
Expand Down
190 changes: 189 additions & 1 deletion crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Unix removal of a file under the swap the design exists for: another actor replaces the last
//! Unix removal of a file, a directory and of a leftover quarantine directory 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
Expand All @@ -7,6 +7,7 @@
use std::fs;
use std::os::fd::AsFd;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Path, PathBuf};

use rustix::fs::{unlinkat, AtFlags};
Expand Down Expand Up @@ -559,3 +560,190 @@ fn an_error_after_the_move_that_cannot_be_rolled_back_is_reported_as_stranded()
)
);
}

#[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 an_empty_directory_swapped_in_the_last_window_is_reported_as_disturbed() {
let f = fixture("window-empty-dir");
fs::create_dir(f.root.join("d")).unwrap();
let original = ino(&f.root.join("d"));
let outcome = f.pinned.remove_empty_dir_observed(
"d",
&mut on(At::Identified, |quarantined| {
let quarantined = quarantined.unwrap();
fs::rename(quarantined, f.aside.join("original-d")).unwrap();
fs::create_dir(quarantined).unwrap();
}),
);
assert_eq!(
(
outcome,
f.root.join("d").exists(),
ino(&f.aside.join("original-d")) == original,
quarantines(&f.root).len()
),
(Ok(RemovalOutcome::Disturbed), false, true, 0)
);
}

/// 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::<String>::new()
)
);
}

#[test]
fn a_leftover_is_taken_by_its_generated_name_only_and_an_empty_one_is_removed() {
let f = fixture("leftover-names");
let generated = format!("{QUARANTINE_PREFIX}{}", "0".repeat(32));
for name in [generated.as_str(), ".h1q-empty", "not-a-quarantine"] {
fs::create_dir(f.root.join(name)).unwrap();
fs::set_permissions(f.root.join(name), fs::Permissions::from_mode(0o700)).unwrap();
}
let taken = |name: &str| f.pinned.remove_leftover_file(name, |_| Ok(true));
assert_eq!(
(
taken("a"),
taken("not-a-quarantine"),
taken(".h1q-empty"),
taken(&format!("{QUARANTINE_PREFIX}{}", "G".repeat(32))),
taken(&generated),
names(&f.pinned)
),
(
Err(PinnedError::InvalidName),
Err(PinnedError::InvalidName),
Err(PinnedError::InvalidName),
Err(PinnedError::InvalidName),
Ok(RemovalOutcome::Vanished),
vec![
".h1q-empty".to_string(),
"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())
)
);
}

#[test]
fn a_stranded_entry_is_finished_by_the_leftover_removal_after_authenticating_it() {
assert!(
!rustix::process::geteuid().is_root(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Root-run tests fail

When the Unix test suite runs with effective UID 0, the non-root assertion in a_stranded_entry_is_finished_by_the_leftover_removal_after_authenticating_it panics before exercising recovery, so the Unix test suite exits unsuccessfully in root-run CI jobs and containers.

Skip the permission-dependent test when the effective UID is root instead of asserting that it is not.

Also at crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs:719.

Prompt for AI agents
In `crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs` at line 720:

**Root-run tests fail**

When the Unix test suite runs with effective UID 0, the non-root assertion in `a_stranded_entry_is_finished_by_the_leftover_removal_after_authenticating_it` panics before exercising recovery, so the Unix test suite exits unsuccessfully in root-run CI jobs and containers.

Skip the permission-dependent test when the effective UID is root instead of asserting that it is not.

Also at `crates/worldscript-secure-storage/src/pinned/tests/remove_unix.rs:719`.

"the permission checks of this case do not apply to root; run the tests as a normal user"
);
let f = fixture("stranded-recovery");
let stranded = f.pinned.remove_authenticated_file_observed(
"a",
holds(b"alpha"),
&mut on(At::MovedAside, lock_the_quarantine),
);
let left = quarantines(&f.root);
fs::set_permissions(&left[0], fs::Permissions::from_mode(0o700)).unwrap();
let name = left[0].file_name().unwrap().to_owned();
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!(
(stranded.is_ok(), refused, finished, names(&f.pinned)),
(
true,
Ok(RemovalOutcome::Refused),
removed(),
Vec::<String>::new()
)
);
}
Loading
Loading