Refuse redirected store metadata paths

The last three hazards of contract review 2026-07-28-D, closed together
because they are one invariant: no name the store *invents* beneath a root
may be reached through a link or resolve to an object of the wrong type. The
flags and descriptor checks are the safety property, so the mechanics go in
the `sys.rs` funnel where the next author looking for how this crate opens
files will find them.

Refusal semantics differ per name, because the names mean different things.
`write_fenced` adopts and empties an existing regular `.tmp` — that is
residue from an interrupted attempt at this exact write, and reusing the name
is how a retry works — which is why the open and the truncation had to be
separated: no flag combination truncates only regular files, so the type
check needs the descriptor first and `O_TRUNC` cannot be in the open.
`read_format` keeps three answers apart: absent stays `Io(NotFound)` because
startup states 1 and 3 depend on it, non-regular is `UnrecognizedLayout`, and
a corrupt regular marker keeps its decode error. `initialize_root` leaves the
caller's root and ancestors alone, adopts existing directories beneath it, and
classifies every planned entry before creating any missing one so a refusal
cannot half-extend the tree it refused.

Directory fences now go to descriptors already validated rather than
re-resolving the name, which would hand the fence to whatever the name
resolves to now instead of what was checked. The fence sequence is otherwise
identical on purpose: `engine.rs` asserts the count exactly.

Each protection was reverted independently and the witnesses observed:

  - `write_fenced` — a 4096-byte file outside the root truncated and
    rewritten through a live link, a file created outside the root through a
    dangling one, and a fifo at the name blocking the open for the full
    ten-second deadline: an unbounded startup hang from one `mkfifo`.
  - `read_format` — a foreign `FORMAT` read in full, its `shard_count` and
    `root_uuid` returned as this root's, so every file in the tree would then
    be validated against a marker the store never wrote. Same hang on the
    read side.
  - `initialize_root` — returned `Ok(FormatMarker)`, reporting a working
    store with its shard tree built outside the root. The preflight has its
    own witness: `shards/00/active` left behind by a refusal that named
    `shards/00/segments`.

The witnesses sit on the `segment` entry points. `StoreEngine::open`'s
classifier refuses a redirected root before any of this is reached, so a test
entering that way passes whether or not the protection exists — and
`RecoverySession::open`, `drive.rs` and `store-bench` all arrive without it.

Disclosed: the device-node residual now covers `FORMAT.tmp` too — one
`O_NONBLOCK` open before the `fstat` refuses it, still gated behind `mknod`
privilege inside a configured root. `rename_noreplace` needed no change;
`RENAME_NOREPLACE` fails `EEXIST` on an occupied target whether or not it is
a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKzM69CHmBuDcA3qN1jFdh
This commit is contained in:
Levi Neuwirth 2026-07-29 21:47:33 -04:00
parent ef73028887
commit 30c464d97d
5 changed files with 780 additions and 48 deletions

View File

@ -827,7 +827,9 @@ fn classify_root(layout: &RootLayout) -> Result<RootStartupState, StoreError> {
// disqualify the root, and a wildcard here is the one edit that would
// silently stop it doing so. The type is part of the name's meaning —
// a symlink called `FORMAT.tmp` is not residue, it is an instruction to
// write outside the root, and `segment::write_fenced` would follow it.
// write outside the root. `segment::write_fenced` refuses one directly
// now as well; this list is why the root is disqualified rather than
// initialized-then-refused, which is a different and better answer.
if !matches!(
(name.to_str(), kind.is_file(), kind.is_dir()),
(Some("FORMAT.tmp"), true, _)
@ -1150,13 +1152,15 @@ fn build_root_under_lock(
/// # Standing disclosure
///
/// This began as a second copy of `segment::write_fenced`, which is private to
/// A1's file, and the duplication was filed as an interface request. The two
/// are no longer the same function: this one cannot truncate and cannot follow
/// a symlink, and `segment::write_fenced` still can. The request therefore
/// changes shape rather than going away — if one of them is hoisted, it must be
/// this one, and `FORMAT.tmp` should be opened through it. That is an interface
/// request against a frozen file and is recorded in this deliverable's report,
/// not made here.
/// A1's file, and the duplication was filed as an interface request. Neither can
/// follow a symlink any more — contract review 2026-07-29-B routed
/// `write_fenced` through the same funnel — so what remains is a real difference
/// in refusal rule rather than in safety: this one refuses an existing regular
/// file, because a marker that already exists is somebody's data and adopting it
/// would be a decision about content; `write_fenced` adopts and empties one,
/// because a `.tmp` name is residue from an interrupted attempt at that exact
/// write and reusing it is how the retry works. Both are correct for their name.
/// Any hoisting therefore has to keep two rules, not pick one.
fn install_initializing_marker(
tmp: &std::path::Path,
bytes: &[u8],
@ -3783,18 +3787,18 @@ mod tests {
/// The same hazard at the other two names this file opens or ignores.
///
/// `segment::lock_root` opens `LOCK` and `segment::write_fenced` opens
/// `FORMAT.tmp` with `create` + `truncate`. Both are in A1's frozen file, so
/// neither open was changed by this deliverable; what it changed is that a
/// root carrying a symlink at either name never reaches them, because the
/// classification that authorizes the write refuses it first.
/// `FORMAT.tmp`. Both are in A1's frozen file, so neither open was changed by
/// this deliverable; what it changed is that a root carrying a symlink at
/// either name never reaches them, because the classification that authorizes
/// the write refuses it first.
///
/// `lock_root` has since been amended by the lead (contract review
/// 2026-07-29-A) to open through the no-follow funnel, because
/// `RecoverySession::open` reaches it without any classification at all —
/// this test therefore no longer carries the `LOCK` guarantee alone, and the
/// assertion below it in `segment.rs` is the load-bearing one. `FORMAT.tmp`
/// is still a follow-through open for any caller that reaches
/// `initialize_root` by another route, and remains an open interface request.
/// Both have since been amended by the lead — `lock_root` in contract review
/// 2026-07-29-A, `write_fenced` in 2026-07-29-B — because
/// `RecoverySession::open`, `drive.rs` and `store-bench` all reach them with
/// no classification at all. This test therefore no longer carries either
/// guarantee on its own; it asserts that the classifier refuses first, which
/// is still the answer an operator wants, and the assertions in `segment.rs`
/// are what hold the opens themselves.
#[test]
fn a_symlink_at_lock_or_format_tmp_is_refused_before_anything_opens_it() {
let serial = writer_serial();

View File

@ -117,6 +117,53 @@ pub fn segment_filename(generation: u64, first: u64, last: u64) -> String {
// Root initialization
// ---------------------------------------------------------------------------
/// `quarantine/`, `staging/`, `shards/` — the store-invented entries directly
/// beneath a root, in the order [`planned_directories`] lists them. `opened[0]`
/// and `opened[2]` in [`initialize_root`] are `quarantine/` and `shards/`.
const STORE_INVENTED_ROOT_ENTRIES: usize = 3;
/// `<shard>/` plus `active`, `segments`, `indexes`, `checkpoints`, `manifests`.
const DIRS_PER_SHARD: usize = 6;
/// Every directory [`initialize_root`] invents beneath the root, parents first.
///
/// Separated from the creation loop because the refusal rule needs the whole set
/// before any of it is created, and because the fencing below indexes into it
/// positionally.
fn planned_directories(layout: &RootLayout, shard_count: u16) -> Vec<PathBuf> {
let mut planned =
Vec::with_capacity(STORE_INVENTED_ROOT_ENTRIES + shard_count as usize * DIRS_PER_SHARD);
planned.push(layout.quarantine_dir());
planned.push(layout.staging_dir());
planned.push(layout.shards_dir());
debug_assert_eq!(planned.len(), STORE_INVENTED_ROOT_ENTRIES);
for shard in 0..shard_count {
let paths = layout.shard(shard);
let before = planned.len();
planned.push(paths.dir.clone());
planned.push(paths.active());
planned.push(paths.segments());
planned.push(paths.indexes());
planned.push(paths.checkpoints());
planned.push(paths.manifests());
debug_assert_eq!(planned.len() - before, DIRS_PER_SHARD);
}
planned
}
/// A name the store must own as a directory, occupied by something else.
///
/// [`StoreError::UnrecognizedLayout`] rather than an `Io`: the store is being
/// asked to build its tree through an object it did not create, which is the
/// same judgement as any other unrecognized occupant of a root, and the same
/// answer — refuse, modify nothing.
fn not_a_directory(path: &Path) -> StoreError {
StoreError::UnrecognizedLayout(format!(
"{} is not a directory; refusing to build the store tree through it",
path.display()
))
}
/// Create the v2 tree and write `FORMAT`, fsyncing every new directory and the
/// root's parent (scope 3.1 startup state 1).
///
@ -136,25 +183,49 @@ pub fn initialize_root(
"shard_count must be nonzero".into(),
));
}
// The root itself, and everything above it, is the caller's path. Resolving
// it is explicitly out of scope (§3.1): an operator who configures a root
// behind a symlink has said where the store goes.
std::fs::create_dir_all(&layout.root)?;
std::fs::create_dir_all(layout.quarantine_dir())?;
std::fs::create_dir_all(layout.staging_dir())?;
std::fs::create_dir_all(layout.shards_dir())?;
for shard in 0..shard_count {
let paths = layout.shard(shard);
for dir in [
paths.dir.clone(),
paths.active(),
paths.segments(),
paths.indexes(),
paths.checkpoints(),
paths.manifests(),
] {
std::fs::create_dir_all(&dir)?;
sys::fsync_dir(&dir, counters)?;
// Everything below is a name *the store invents*, and none of it may be
// reached through a link. Two passes, because a refusal must not leave the
// tree half-extended: pass one classifies every planned directory and holds
// a descriptor onto each that already exists, pass two creates the rest.
// Ordered parents-before-children so pass two never creates through a name
// pass one did not see.
let planned = planned_directories(layout, shard_count);
let mut found = Vec::with_capacity(planned.len());
for directory in &planned {
match sys::open_directory_nofollow(directory)? {
sys::NamedDirectory::Opened(handle) => found.push(Some(handle)),
sys::NamedDirectory::Absent => found.push(None),
sys::NamedDirectory::NotDirectory => return Err(not_a_directory(directory)),
}
sys::fsync_dir(&paths.dir, counters)?;
}
let mut opened = Vec::with_capacity(planned.len());
for (directory, existing) in planned.iter().zip(found) {
opened.push(match existing {
Some(handle) => handle,
None => sys::create_directory_nofollow(directory)?
.ok_or_else(|| not_a_directory(directory))?,
});
}
// Fenced through the descriptors just established, not by reopening their
// names: a second lookup would hand the fence to whatever the name resolves
// to now rather than to the directory that was validated.
//
// The shape of this sequence is load-bearing and unchanged — six directories
// per shard plus the shard directory again, which `engine.rs` asserts as an
// exact `fsync_dir` count.
for shard in 0..shard_count as usize {
let shard_directories =
&opened[STORE_INVENTED_ROOT_ENTRIES + shard * DIRS_PER_SHARD..][..DIRS_PER_SHARD];
for directory in shard_directories {
sys::fsync_dir_fd(directory, counters)?;
}
sys::fsync_dir_fd(&shard_directories[0], counters)?;
}
let marker = crate::format::FormatMarker {
@ -169,8 +240,8 @@ pub fn initialize_root(
write_fenced(&tmp, &bytes, counters)?;
sys::rename_noreplace(&tmp, &layout.format_path())?;
sys::fsync_dir(&layout.shards_dir(), counters)?;
sys::fsync_dir(&layout.quarantine_dir(), counters)?;
sys::fsync_dir_fd(&opened[2], counters)?;
sys::fsync_dir_fd(&opened[0], counters)?;
sys::fsync_dir(&layout.root, counters)?;
if let Some(parent) = layout.root.parent() {
sys::fsync_dir(parent, counters)?;
@ -179,8 +250,38 @@ pub fn initialize_root(
}
/// Read and validate `FORMAT`.
///
/// Three answers, kept distinct because callers act on them differently:
///
/// - **Absent** — `Io(NotFound)`, exactly as `File::open` reported it. An absent
/// `FORMAT` is startup state 1 or 3, not a refusal, and `classify_root` and
/// `drive.rs` both read it that way.
/// - **Not a regular file** — [`StoreError::UnrecognizedLayout`]. A symlink here
/// is the read-only member of the redirection family: following it would let a
/// `FORMAT` outside the root decide this root's `shard_count` and `root_uuid`,
/// and every file in the tree is then validated against a marker the store
/// never wrote. Nothing is destroyed, which is why it was ranked lowest of the
/// four, and it is still an authority the operator did not grant.
/// - **A regular file that does not decode** — unchanged. `FormatMismatch` or a
/// decode error, which is a corrupt marker and a different finding from a
/// redirected one.
pub fn read_format(layout: &RootLayout) -> Result<crate::format::FormatMarker, StoreError> {
let file = File::open(layout.format_path())?;
let path = layout.format_path();
let file = match sys::open_regular_nofollow_classified(&path)? {
sys::NamedRegularFile::Opened(file) => file,
sys::NamedRegularFile::Absent => {
return Err(StoreError::Io(Arc::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} not found", path.display()),
))))
}
sys::NamedRegularFile::NotRegular => {
return Err(StoreError::UnrecognizedLayout(format!(
"{} is not a regular file; refusing to read a format marker through it",
path.display()
)))
}
};
let mut bytes = [0u8; FORMAT_MARKER_LEN];
sys::pread_exact(&file, 0, &mut bytes)?;
Ok(crate::format::FormatMarker::decode(&bytes)?)
@ -296,16 +397,34 @@ pub fn lock_root(layout: &RootLayout) -> Result<RootLock, StoreError> {
/// Write a file and fence it. The name is not yet durable; the caller renames
/// and syncs the directory.
///
/// # Why the open is not `create(true).truncate(true)`
///
/// Every caller passes a `.tmp` name inside the root — `FORMAT.tmp`,
/// `<generation>.manifest.tmp`, `CURRENT.tmp` — and an `O_TRUNC` open destroys
/// whatever the name holds before anything can look at it. Through a symlink it
/// destroys a file *outside* the root: measured at `INITIALIZING.tmp` earlier in
/// this wave, a 4096-byte file went to 27 bytes with the link removed and the
/// store reporting success. These names are as reachable to an operator as that
/// one was.
///
/// So the open and the truncation are separated: no flag combination truncates
/// only regular files, which is why the type check needs the descriptor first.
/// An existing regular file is **kept and emptied**, not refused — it is residue
/// from an interrupted attempt at this exact write, and reusing the name is how
/// the retry works. Anything else at the name is
/// [`StoreError::UnrecognizedLayout`].
fn write_fenced(
path: &Path,
bytes: &[u8],
counters: &DurabilityCounters,
) -> Result<(), StoreError> {
let mut file = File::options()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
let Some(mut file) = sys::open_or_create_regular_truncated_nofollow(path, counters)? else {
return Err(StoreError::UnrecognizedLayout(format!(
"{} is not a regular file; refusing to write a store metadata file through it",
path.display()
)));
};
// Through the funnel, so the counters see every durable byte.
sys::write_vectored_all(&mut file, &[IoSlice::new(bytes)], counters)?;
sys::fdatasync(&file, counters)?;
@ -1337,3 +1456,395 @@ mod root_lock_tests {
);
}
}
// ---------------------------------------------------------------------------
// Path-redirection tests (contract review 2026-07-29-B)
// ---------------------------------------------------------------------------
/// The three remaining redirection hazards, each exercised at the `segment`
/// entry point that carries it.
///
/// Deliberately **not** through `StoreEngine::open`. Its classifier refuses a
/// redirected root before these are reached, so a test that went in that way
/// would pass whether or not the protection here exists — and `RecoverySession`,
/// `drive.rs` and `store-bench` all arrive here without it. Coverage of the
/// caller is not coverage of the callee.
#[cfg(test)]
mod path_redirection_tests {
use super::*;
use std::os::unix::fs::symlink;
fn counters() -> DurabilityCounters {
DurabilityCounters::default()
}
/// Every name in a tree with its bytes, for "unchanged" assertions.
fn tree_image(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&current) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let relative = path
.strip_prefix(root)
.expect("inside the tree")
.to_path_buf();
let kind = entry.file_type().expect("file type");
if kind.is_dir() {
stack.push(path);
out.push((relative, None));
} else {
out.push((relative, std::fs::read(&path).ok()));
}
}
}
out.sort();
out
}
fn mkfifo_at(path: &Path) {
let name = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
.expect("a path with no interior NUL");
// SAFETY: `name` is a valid NUL-terminated path for the call's duration.
assert_eq!(
unsafe { libc::mkfifo(name.as_ptr(), 0o644) },
0,
"mkfifo: {}",
std::io::Error::last_os_error()
);
}
/// Run `body` on another thread and fail if it does not finish.
///
/// A blocking `open(2)` is not a slow refusal, it is an unbounded startup
/// hang: one `mkfifo` in a configured root, reachable by any operator. The
/// only assertion that distinguishes the two is a deadline.
fn must_not_block<T: Send + 'static>(
what: &str,
body: impl FnOnce() -> T + Send + 'static,
) -> T {
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(body());
});
match receiver.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(value) => value,
Err(_) => panic!(
"{what} did not return within ten seconds: the open blocked. A fifo at a store \
metadata name must be refused, not waited on."
),
}
}
// -----------------------------------------------------------------------
// write_fenced
// -----------------------------------------------------------------------
/// A live link at a temporary name must not reach its target at all.
#[test]
fn a_linked_temporary_name_cannot_alter_the_file_it_points_at() {
let outside = tempfile::tempdir().expect("tempdir");
let victim = outside.path().join("ledger");
std::fs::write(&victim, b"balances\n").expect("the file outside the root");
let root = tempfile::tempdir().expect("temp root");
let tmp = root.path().join("FORMAT.tmp");
symlink(&victim, &tmp).expect("plant the link");
let outcome = write_fenced(&tmp, b"a store metadata marker", &counters());
// The damage first, so a regression reports what was destroyed rather
// than which error type came back.
assert_eq!(
std::fs::read(&victim).expect("the target still exists"),
b"balances\n",
"a fenced write through a symlink truncated and rewrote a file outside the root"
);
assert!(
std::fs::symlink_metadata(&tmp).is_ok(),
"the link itself must be left alone for the operator to find"
);
match outcome {
Err(StoreError::UnrecognizedLayout(reason)) => {
assert!(reason.contains("FORMAT.tmp"), "{reason}")
}
other => panic!("expected UnrecognizedLayout, got {other:?}"),
}
}
/// The dangling form, which creates rather than destroys.
#[test]
fn a_dangling_link_at_a_temporary_name_creates_nothing() {
let outside = tempfile::tempdir().expect("tempdir");
let absent = outside.path().join("not-there");
let root = tempfile::tempdir().expect("temp root");
let tmp = root.path().join("FORMAT.tmp");
symlink(&absent, &tmp).expect("plant the link");
let outcome = write_fenced(&tmp, b"a store metadata marker", &counters());
assert!(
!absent.exists(),
"a fenced write through a dangling symlink created a file outside the root"
);
match outcome {
Err(StoreError::UnrecognizedLayout(_)) => {}
other => panic!("expected UnrecognizedLayout, got {other:?}"),
}
}
/// Every non-regular occupant, and the one occupant that is legitimate.
#[test]
fn a_non_regular_temporary_name_is_refused_and_a_regular_one_is_retried() {
let mut wrong = Vec::new();
for (label, occupy) in [
(
"directory",
(|path: &Path| std::fs::create_dir(path).expect("mkdir")) as fn(&Path),
),
("unix socket", |path: &Path| {
std::os::unix::net::UnixListener::bind(path).expect("bind");
}),
("fifo", mkfifo_at),
] {
let root = tempfile::tempdir().expect("temp root");
let tmp = root.path().join("FORMAT.tmp");
occupy(&tmp);
let attempt = tmp.clone();
let outcome = must_not_block(label, move || {
write_fenced(
&attempt,
b"a store metadata marker",
&DurabilityCounters::default(),
)
});
match outcome {
Err(StoreError::UnrecognizedLayout(_)) => {}
other => wrong.push(format!("{label}: {other:?}")),
}
}
assert!(
wrong.is_empty(),
"a non-regular temporary name must be UnrecognizedLayout, not written through and \
not an untyped errno: {wrong:?}"
);
// Residue from an interrupted attempt at this same write. Retrying is the
// point of a `.tmp` name, so it is adopted — and emptied, which the
// stale tail proves: it is longer than the new contents, so a missing
// truncation leaves it visible.
let root = tempfile::tempdir().expect("temp root");
let tmp = root.path().join("FORMAT.tmp");
std::fs::write(
&tmp,
b"residue from an interrupted attempt, longer than what follows",
)
.expect("stale residue");
write_fenced(&tmp, b"the retry", &counters()).expect("an existing regular temp is retried");
assert_eq!(
std::fs::read(&tmp).expect("read the temp"),
b"the retry",
"a retried fenced write must leave exactly the new bytes"
);
}
// -----------------------------------------------------------------------
// read_format
// -----------------------------------------------------------------------
/// A valid marker at the far end of a link is still not this root's marker.
///
/// The target holds a real, decodable `FORMAT` — built by `initialize_root`,
/// not hand-rolled — so the refusal cannot be mistaken for the bytes being
/// rejected. Read as a regular file the same bytes are accepted, which the
/// second half asserts: without it this test would pass against an
/// implementation that had simply broken `read_format`.
#[test]
fn a_linked_format_marker_is_never_read_even_when_it_is_valid() {
let donor = tempfile::tempdir().expect("temp root");
let donor_layout = RootLayout::new(donor.path());
initialize_root(&donor_layout, 1, [0x5A; 16], 1, &counters()).expect("a real root");
let valid = std::fs::read(donor_layout.format_path()).expect("real FORMAT bytes");
let outside = tempfile::tempdir().expect("tempdir");
let target = outside.path().join("someone-elses-FORMAT");
std::fs::write(&target, &valid).expect("a valid marker outside the root");
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
symlink(&target, layout.format_path()).expect("plant the link");
match read_format(&layout) {
Err(StoreError::UnrecognizedLayout(reason)) => {
assert!(reason.contains("FORMAT"), "{reason}")
}
Ok(marker) => panic!(
"a FORMAT symlink was followed: this root's shard_count and root_uuid would come \
from a marker outside it ({marker:?}), and every file in the tree would then be \
validated against a marker the store never wrote"
),
Err(other) => panic!("expected UnrecognizedLayout, got {other:?}"),
}
// The same bytes, as a regular file, are read.
std::fs::remove_file(layout.format_path()).expect("remove the link");
std::fs::write(layout.format_path(), &valid).expect("the same bytes, directly");
read_format(&layout).expect("a regular FORMAT holding valid bytes must still be read");
}
/// The other two answers, which are not refusals and must not become ones.
#[test]
fn an_absent_format_is_not_found_and_a_corrupt_one_still_fails_to_decode() {
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
match read_format(&layout) {
Err(StoreError::Io(error)) => assert_eq!(
error.kind(),
std::io::ErrorKind::NotFound,
"an absent FORMAT is startup state 1 or 3 and must keep reporting NotFound"
),
other => panic!("expected Io(NotFound), got {other:?}"),
}
std::fs::write(layout.format_path(), [0xAB; FORMAT_MARKER_LEN]).expect("garbage");
match read_format(&layout) {
Err(StoreError::UnrecognizedLayout(reason)) => panic!(
"a corrupt *regular* marker is a different finding from a redirected one and \
must keep its decode error: {reason}"
),
Err(_) => {}
Ok(marker) => panic!("garbage decoded as a marker: {marker:?}"),
}
}
/// A fifo at `FORMAT` is the read-side hang. `open(2)` read-only on one
/// blocks until a writer arrives, so this is the case `O_NONBLOCK` in the
/// funnel exists for.
#[test]
fn a_fifo_at_format_is_refused_rather_than_waited_on() {
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
mkfifo_at(&layout.format_path());
let probed = RootLayout::new(root.path().to_path_buf());
match must_not_block("read_format on a fifo", move || read_format(&probed)) {
Err(StoreError::UnrecognizedLayout(_)) => {}
other => panic!("expected UnrecognizedLayout, got {other:?}"),
}
}
// -----------------------------------------------------------------------
// initialize_root
// -----------------------------------------------------------------------
/// A link where the store expects to invent a directory.
///
/// Both positions: `shards/` itself, and one subdirectory of one shard. In
/// each case `create_dir_all` would have followed the link and built the
/// store's tree at the far end of it.
#[test]
fn a_linked_directory_name_creates_nothing_outside_the_root() {
for position in ["shards", "shards/00/segments"] {
let outside = tempfile::tempdir().expect("tempdir");
let elsewhere = outside.path().join("elsewhere");
std::fs::create_dir(&elsewhere).expect("a directory outside the root");
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
let planted = root.path().join(position);
if let Some(parent) = planted.parent() {
std::fs::create_dir_all(parent).expect("the parent the link sits in");
}
symlink(&elsewhere, &planted).expect("plant the link");
let outcome = initialize_root(&layout, 2, [0x11; 16], 1, &counters());
// The damage first: a regression here is the tree being built
// somewhere it must never be, not the error type.
assert_eq!(
tree_image(&elsewhere),
Vec::new(),
"initialize_root built part of the store tree outside the root, through {position}"
);
match outcome {
Err(StoreError::UnrecognizedLayout(reason)) => {
assert!(
reason.contains(position.rsplit('/').next().expect("name")),
"{reason}"
)
}
other => {
panic!("expected UnrecognizedLayout for a link at {position}, got {other:?}")
}
}
}
}
/// The preflight, which is what stops a refusal from half-extending a tree.
///
/// The link sits at `shards/00/segments`, and the two names created *before*
/// it in creation order are `shards/00` (which exists already) and
/// `shards/00/active` (which does not). A single pass that created as it went
/// would leave `active` behind; the assertion is that it does not exist, and
/// that the second shard was never begun.
#[test]
fn a_refusal_does_not_extend_the_tree_it_refused() {
let outside = tempfile::tempdir().expect("tempdir");
let elsewhere = outside.path().join("elsewhere");
std::fs::create_dir(&elsewhere).expect("a directory outside the root");
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
// A partially built tree, as an interrupted initialization leaves.
std::fs::create_dir_all(layout.quarantine_dir()).expect("quarantine");
std::fs::write(layout.quarantine_dir().join("keep"), b"forensics\n").expect("residue");
std::fs::create_dir_all(layout.shard(0).dir).expect("the first shard directory");
symlink(&elsewhere, layout.shard(0).segments()).expect("plant the link");
let before = tree_image(root.path());
match initialize_root(&layout, 2, [0x22; 16], 1, &counters()) {
Err(StoreError::UnrecognizedLayout(_)) => {}
other => panic!("expected UnrecognizedLayout, got {other:?}"),
}
assert!(
!layout.shard(0).active().exists(),
"the refusal created shards/00/active on its way to the link: every existing entry \
must be classified before any missing one is created"
);
assert!(
!layout.shard(1).dir.exists(),
"the refusal began a second shard"
);
assert_eq!(
tree_image(root.path()),
before,
"a refused initialization must leave the tree it found byte-identical"
);
}
/// A non-directory occupant, and the legitimate case beside it: an existing
/// partial tree is adopted rather than refused, because that is what resuming
/// an interrupted initialization requires.
#[test]
fn a_non_directory_occupant_is_refused_and_a_real_partial_tree_is_adopted() {
let root = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(root.path());
std::fs::create_dir_all(&layout.root).expect("root");
std::fs::write(layout.shards_dir(), b"not a directory\n").expect("occupy shards");
match initialize_root(&layout, 1, [0x33; 16], 1, &counters()) {
Err(StoreError::UnrecognizedLayout(_)) => {}
other => panic!("expected UnrecognizedLayout for a file at shards/, got {other:?}"),
}
let resumable = tempfile::tempdir().expect("temp root");
let layout = RootLayout::new(resumable.path());
std::fs::create_dir_all(layout.shard(0).active()).expect("a partial tree");
std::fs::create_dir_all(layout.staging_dir()).expect("staging");
let marker = initialize_root(&layout, 1, [0x44; 16], 1, &counters())
.expect("an existing partial tree is adopted, not refused");
assert_eq!(marker.shard_count, 1);
assert!(layout.shard(0).manifests().is_dir());
read_format(&layout).expect("the marker is installed over an adopted tree");
}
}

View File

@ -283,6 +283,12 @@ pub(crate) fn fdatasync(file: &File, counters: &DurabilityCounters) -> io::Resul
/// Make a directory entry durable. Required after every create, rename, and
/// unlink that recovery depends on.
///
/// Resolves the name, so it is for directories whose path the store already
/// trusts. A caller that has just *established* what a name is holds a
/// descriptor onto that object and should fence it with [`fsync_dir_fd`]
/// instead — re-resolving the name would hand the fence back to whatever the
/// name resolves to now, which is not necessarily what was validated.
pub(crate) fn fsync_dir(path: &Path, counters: &DurabilityCounters) -> io::Result<()> {
if take_fault_if(|f| matches!(f, Fault::DirSyncEio)).is_some() {
counters.fsync_dir.fetch_add(1, Relaxed);
@ -292,6 +298,19 @@ pub(crate) fn fsync_dir(path: &Path, counters: &DurabilityCounters) -> io::Resul
File::open(path)?.sync_all()
}
/// [`fsync_dir`] on a directory descriptor the caller already holds.
///
/// Same counter and same injected fault, so the crash matrix reaches this seam
/// exactly as it reaches the by-name one.
pub(crate) fn fsync_dir_fd(directory: &File, counters: &DurabilityCounters) -> io::Result<()> {
if take_fault_if(|f| matches!(f, Fault::DirSyncEio)).is_some() {
counters.fsync_dir.fetch_add(1, Relaxed);
return Err(eio("directory fsync"));
}
counters.fsync_dir.fetch_add(1, Relaxed);
directory.sync_all()
}
// ---------------------------------------------------------------------------
// Naming
// ---------------------------------------------------------------------------
@ -370,6 +389,29 @@ pub(crate) fn unlink(path: &Path) -> io::Result<()> {
/// reached by `mkfifo`, and one this function exists to be immune to because
/// its whole job is to look at names it does not trust.
pub(crate) fn open_regular_nofollow(path: &Path) -> io::Result<Option<File>> {
Ok(match open_regular_nofollow_classified(path)? {
NamedRegularFile::Opened(file) => Some(file),
NamedRegularFile::Absent | NamedRegularFile::NotRegular => None,
})
}
/// What a name held, for the callers that must tell "absent" from "occupied by
/// something else".
///
/// [`open_regular_nofollow`] collapses the two, which is right for a caller
/// asking "are the bytes at this name mine" — every non-regular answer means no.
/// It is wrong for `segment::read_format`, where an absent `FORMAT` is an
/// ordinary startup state that must keep reporting `NotFound`, and a `FORMAT`
/// that is a symlink is a refusal. Two questions, so two answers.
pub(crate) enum NamedRegularFile {
Opened(File),
Absent,
/// A symlink, a directory, a fifo, a socket, or a device.
NotRegular,
}
/// [`open_regular_nofollow`], keeping absence and wrong-type apart.
pub(crate) fn open_regular_nofollow_classified(path: &Path) -> io::Result<NamedRegularFile> {
use rustix::fs::{FileType, Mode, OFlags};
let fd = match rustix::fs::open(
path,
@ -377,20 +419,29 @@ pub(crate) fn open_regular_nofollow(path: &Path) -> io::Result<Option<File>> {
Mode::empty(),
) {
Ok(fd) => fd,
Err(rustix::io::Errno::NOENT) | Err(rustix::io::Errno::NOTDIR) => return Ok(None),
Err(rustix::io::Errno::NOENT) => return Ok(NamedRegularFile::Absent),
// A non-directory component *within* the path, which is not this name
// being absent — the store cannot create through it either.
Err(rustix::io::Errno::NOTDIR) => return Ok(NamedRegularFile::NotRegular),
// `O_NOFOLLOW` on a symlink is `ELOOP` on Linux and `EMLINK` on some
// BSDs. Both mean "the final component is a symlink", which is exactly
// the answer this function is being asked for.
Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => return Ok(None),
Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => {
return Ok(NamedRegularFile::NotRegular)
}
// A socket refuses `open(2)` outright; see
// [`open_or_create_regular_nofollow`] for why this is mapped rather than
// returned as an `Io`.
Err(rustix::io::Errno::NXIO) => return Ok(NamedRegularFile::NotRegular),
Err(e) => return Err(io::Error::from_raw_os_error(e.raw_os_error())),
};
let file = File::from(fd);
let stat =
rustix::fs::fstat(&file).map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;
if FileType::from_raw_mode(stat.st_mode) == FileType::RegularFile {
Ok(Some(file))
Ok(NamedRegularFile::Opened(file))
} else {
Ok(None)
Ok(NamedRegularFile::NotRegular)
}
}
@ -465,6 +516,14 @@ pub(crate) fn create_new_nofollow(path: &Path) -> io::Result<Option<File>> {
/// warrants: reaching it requires the privilege to `mknod` inside a configured
/// store root. Recorded rather than silently accepted.
pub(crate) fn open_or_create_regular_nofollow(path: &Path) -> io::Result<Option<File>> {
Ok(open_or_create_regular_nofollow_sized(path)?.map(|(file, _)| file))
}
/// [`open_or_create_regular_nofollow`], also reporting the length it found.
///
/// The length is what lets [`open_or_create_regular_truncated_nofollow`] leave
/// the durability counters alone when there was nothing to truncate.
fn open_or_create_regular_nofollow_sized(path: &Path) -> io::Result<Option<(File, u64)>> {
use rustix::fs::{FileType, Mode, OFlags};
let fd = match rustix::fs::open(
path,
@ -490,12 +549,104 @@ pub(crate) fn open_or_create_regular_nofollow(path: &Path) -> io::Result<Option<
let stat =
rustix::fs::fstat(&file).map_err(|e| io::Error::from_raw_os_error(e.raw_os_error()))?;
if FileType::from_raw_mode(stat.st_mode) == FileType::RegularFile {
Ok(Some(file))
Ok(Some((file, stat.st_size as u64)))
} else {
Ok(None)
}
}
/// [`open_or_create_regular_nofollow`], left empty and ready to be rewritten.
///
/// For the temporary names a fenced write installs from — `FORMAT.tmp`,
/// `<generation>.manifest.tmp`, `CURRENT.tmp`. The operation these replace is
/// `File::options().create(true).write(true).truncate(true)`, whose `O_TRUNC`
/// destroys whatever the name held **before** anything can look at it, and
/// through a symlink destroys it outside the root entirely. Splitting the open
/// from the truncation is what makes the type check possible at all: there is
/// no flag combination that truncates only regular files.
///
/// An existing regular file here is legitimate — it is residue from an
/// interrupted attempt at exactly this write, and reusing the name is how a
/// retry works — so it is adopted and emptied rather than refused.
///
/// The truncation goes through [`truncate`] and is therefore counted, because a
/// truncation is a durability-relevant mutation; it is skipped when the file is
/// already empty, which is every freshly created one, so the common path adds no
/// syscall and no counter movement.
pub(crate) fn open_or_create_regular_truncated_nofollow(
path: &Path,
counters: &DurabilityCounters,
) -> io::Result<Option<File>> {
let Some((file, length)) = open_or_create_regular_nofollow_sized(path)? else {
return Ok(None);
};
if length != 0 {
truncate(&file, 0, counters)?;
}
Ok(Some(file))
}
/// What a name held, for the directories the store invents beneath a root.
pub(crate) enum NamedDirectory {
Opened(File),
Absent,
/// A symlink — including one that resolves to a perfectly good directory —
/// a regular file, or any other non-directory.
NotDirectory,
}
/// Open `path` as a directory, refusing to traverse a symlink at the final
/// component.
///
/// `O_DIRECTORY` is the type check here and it happens in the kernel, before the
/// descriptor exists: there is no window in which a non-directory is open. A
/// symlink *to* a directory is refused as firmly as a symlink to anything else,
/// which is the whole point — `create_dir_all` follows one and builds the store's
/// tree wherever it leads.
pub(crate) fn open_directory_nofollow(path: &Path) -> io::Result<NamedDirectory> {
use rustix::fs::{Mode, OFlags};
match rustix::fs::open(
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => Ok(NamedDirectory::Opened(File::from(fd))),
Err(rustix::io::Errno::NOENT) => Ok(NamedDirectory::Absent),
// `ENOTDIR` is the name holding a non-directory, or a non-directory
// component within the path; `ELOOP`/`EMLINK` is a symlink at the final
// component. The store may build through none of them.
Err(rustix::io::Errno::NOTDIR)
| Err(rustix::io::Errno::LOOP)
| Err(rustix::io::Errno::MLINK) => Ok(NamedDirectory::NotDirectory),
Err(e) => Err(io::Error::from_raw_os_error(e.raw_os_error())),
}
}
/// Create `path` as a directory and open it, or report that the name is taken
/// by something that is not one.
///
/// `mkdir(2)` never follows a symlink at the final component — a name occupied
/// by one fails `EEXIST` whether or not it resolves — so this cannot create a
/// directory outside the directory it names. `Ok(None)` is that `EEXIST` where
/// the occupant turns out not to be a directory; an occupant that *is* one is
/// adopted, because two callers racing to create the same tree is ordinary.
pub(crate) fn create_directory_nofollow(path: &Path) -> io::Result<Option<File>> {
use rustix::fs::Mode;
match rustix::fs::mkdir(path, Mode::from_raw_mode(0o755)) {
Ok(()) => {}
Err(rustix::io::Errno::EXIST) => {}
Err(e) => return Err(io::Error::from_raw_os_error(e.raw_os_error())),
}
Ok(match open_directory_nofollow(path)? {
NamedDirectory::Opened(file) => Some(file),
NamedDirectory::NotDirectory => None,
// Unlinked between the `mkdir` and the open. Nothing was created that
// survives, and the caller's tree is not what it asked for, so this is
// the same answer as a name it may not use.
NamedDirectory::Absent => None,
})
}
/// Truncate a file to `len`.
///
/// In the funnel because it changes what is durable. `journal.rs` was calling

View File

@ -1307,6 +1307,67 @@ with the charter's item 8 — assert against the path that runs — as its analo
they mean here, and the socket case is now one of four occupants the test loop covers. Safety was
never affected; the type of the refusal was.
##### Contract review 2026-07-29-B
**The remaining three redirection hazards of 2026-07-28-D are closed.** One commit, one owner, one
invariant: no name the store *invents* beneath a root may be reached through a link or resolve to an
object of the wrong type. `sys.rs` gains the mechanics — the flags and the descriptor checks are the
safety property, so they belong in the funnel where the next author looking for how this crate opens
files will find them.
Refusal semantics, decided per name rather than uniformly, because the three names mean different
things:
- **`write_fenced`** (`FORMAT.tmp`, `<generation>.manifest.tmp`, `CURRENT.tmp`). An existing regular
file is **adopted and emptied**: it is residue from an interrupted attempt at this exact write, and
reusing the name is how a retry works. The open and the truncation had to be separated to make that
possible at all — no flag combination truncates only regular files, so the type check needs the
descriptor first, and `O_TRUNC` cannot be in the open. Any non-regular occupant is
`UnrecognizedLayout`. The truncation goes through the funnel's `truncate` and is counted; it is
skipped when the file is already empty, so the common create path moves no counter.
- **`read_format`**. Three answers kept apart, because callers act on them differently: absent stays
`Io(NotFound)` — startup states 1 and 3 depend on it — non-regular is `UnrecognizedLayout`, and a
corrupt *regular* marker keeps its existing decode error, which is a different finding from a
redirected one.
- **`initialize_root`**. The root and its ancestors stay the caller's path: an operator who configures
a root behind a symlink has said where the store goes, and resolving that is out of scope. Every
directory *beneath* it is store-invented — existing directories adopted, absent ones created,
symlinks and non-directory occupants `UnrecognizedLayout`. Two passes: every planned entry is
classified before any missing one is created, so a refusal cannot half-extend the tree it refused.
`O_DIRECTORY` is the type check and the kernel applies it before the descriptor exists, so there is
no window in which a non-directory is open; `mkdir(2)` never follows a final symlink, so the create
side needs no separate guard.
**Directory fences now go to descriptors already validated** (`sys::fsync_dir_fd`) rather than
re-resolving the name, which would hand the fence to whatever the name resolves to *now* instead of to
what was checked. The fence sequence is otherwise byte-for-byte the same, deliberately: `engine.rs`
asserts `initialize_root`'s `fsync_dir` count exactly, and that assertion is load-bearing evidence
about initialization, not incidental.
Each of the three protections was reverted independently and the witnesses observed, not predicted:
- `write_fenced` — a 4096-byte file outside the root truncated and rewritten through a live link; a
file *created* outside the root through a dangling one; and a fifo at the name **blocked the open
for the full ten-second deadline**, an unbounded startup hang from one `mkfifo`.
- `read_format` — a foreign `FORMAT` read in full and its `shard_count` and `root_uuid` returned as
this root's, so every file in the tree would then be validated against a marker the store never
wrote; and the same ten-second hang at `FORMAT` on the read side.
- `initialize_root` — returned **`Ok(FormatMarker)`**: a successful initialization reporting a working
store, with the shard tree built outside the root through a link at `shards/`. The two-pass
preflight has its own witness, `shards/00/active` existing after a refusal that named
`shards/00/segments`.
The witnesses sit on the `segment` entry points, not on `StoreEngine::open`. Its classifier refuses a
redirected root before any of these are reached, so a test entering that way passes whether or not the
protection exists — and `RecoverySession::open`, `drive.rs` and `store-bench` all arrive without it.
Coverage of the caller is not coverage of the callee.
**Disclosed.** The device-node residual from 2026-07-29-A now applies to `FORMAT.tmp` as well: one
`O_NONBLOCK` open lands before the `fstat` refuses it. Unchanged in judgement — it needs `mknod`
privilege inside a configured store root. `rename_noreplace` needed no change: `renameat2` with
`RENAME_NOREPLACE` fails `EEXIST` on an occupied target name whether or not it is a link, so the
`FORMAT.tmp``FORMAT` install could never have followed one.
##### Contract review 2026-07-28-C
B4 re-pointed `store-bench` at a real `StoreEngine::submit` and found that four verification

View File

@ -415,6 +415,11 @@ long as the name keeps resolving to the object the holder locked.
Two cases follow, and only one of them is a defect the store can close:
The same rule now holds for every other name the store invents beneath a root — `FORMAT`, the `.tmp`
names a fenced write installs from, and every directory in the tree (contract review 2026-07-29-B).
The root itself and its ancestors are excluded on purpose: an operator who configures a root behind a
symlink has said where the store goes.
1. **The name already resolves elsewhere when a process arrives.** A symlink, a fifo, a
directory, a socket, or a device at `LOCK` — left by an operator, a restored backup, a
symlink farm, or a previous tenant of the directory. A follow-through open takes the